Fix RBAC, user creation and update components

This commit is contained in:
Krikorios
2026-05-21 20:34:57 +03:00
parent 1f23102050
commit 654b524f1b
18 changed files with 1489 additions and 174 deletions
+1
View File
@@ -30,6 +30,7 @@ create table if not exists auth.users (
email citext unique,
password_hash text not null,
full_name text,
is_system_admin boolean not null default false,
is_active boolean not null default true,
created_at timestamptz not null default now(),
last_login_at timestamptz
+84
View File
@@ -30,3 +30,87 @@ alter table app.employees disable row level security;
alter table app.employee_transactions disable row level security;
grant select, insert, update, delete on app.employees to authenticated;
grant select, insert, update, delete on app.employee_transactions to authenticated;
-- Report-ready bridge from the modern POS/shift ledger into the legacy
-- employee payment report shape. A negative closed-shift variance means the
-- cashier is short, so it increases outstanding collection. A positive
-- variance means the drawer is over, so it is treated as a deposit/credit.
create or replace view app.v_employee_outstanding_balances as
with manual as (
select
e.id as employee_id,
e.emp_id,
e.name,
e.email,
e.department,
e.location,
et.currency,
sum(et.collection_amount) as manual_collection,
sum(et.deposit_amount) as manual_deposit,
0::numeric as shift_shortage,
0::numeric as shift_overage,
max(et.transaction_date)::timestamptz as last_activity_at
from app.employees e
join app.employee_transactions et on et.employee_id = e.id
group by e.id, e.emp_id, e.name, e.email, e.department, e.location, et.currency
), shift_variance as (
select
e.id as employee_id,
coalesce(e.emp_id, 'AUTH-' || left(u.id::text, 8)) as emp_id,
coalesce(e.name, p.full_name, u.full_name, u.email) as name,
u.email,
e.department,
e.location,
currency_rows.currency,
0::numeric as manual_collection,
0::numeric as manual_deposit,
sum(greatest(-currency_rows.variance_amount, 0)) as shift_shortage,
sum(greatest(currency_rows.variance_amount, 0)) as shift_overage,
max(sh.closed_at) as last_activity_at
from app.shifts sh
join auth.users u on u.id = sh.user_id
left join app.user_profiles p on p.user_id = u.id
left join app.employees e on lower(e.email) = lower(u.email)
cross join lateral (values
('USD'::text, coalesce(sh.variance_usd, 0)::numeric),
('LBP'::text, coalesce(sh.variance_lbp, 0)::numeric)
) as currency_rows(currency, variance_amount)
where sh.status = 'closed'
and currency_rows.variance_amount <> 0
group by e.id, coalesce(e.emp_id, 'AUTH-' || left(u.id::text, 8)),
coalesce(e.name, p.full_name, u.full_name, u.email), u.email,
e.department, e.location, currency_rows.currency
), combined as (
select * from manual
union all
select * from shift_variance
)
select
coalesce(
employee_id,
(
substr(md5(coalesce(email, emp_id)), 1, 8) || '-' ||
substr(md5(coalesce(email, emp_id)), 9, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 13, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 17, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 21, 12)
)::uuid
) as employee_id,
emp_id,
name,
email,
department,
location,
currency,
sum(manual_collection) as manual_collection,
sum(manual_deposit) as manual_deposit,
sum(shift_shortage) as shift_shortage,
sum(shift_overage) as shift_overage,
sum(manual_collection + shift_shortage) as total_collection,
sum(manual_deposit + shift_overage) as total_deposit,
sum(manual_collection + shift_shortage - manual_deposit - shift_overage) as outstanding_amount,
max(last_activity_at) as last_activity_at
from combined
group by employee_id, emp_id, name, email, department, location, currency;
grant select on app.v_employee_outstanding_balances to authenticated;
+3 -2
View File
@@ -24,11 +24,12 @@ declare
v_user_id uuid;
v_shop_id uuid;
begin
insert into auth.users(email, password_hash, full_name, is_active)
values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true)
insert into auth.users(email, password_hash, full_name, is_system_admin, is_active)
values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true, true)
on conflict (email) do update
set password_hash = excluded.password_hash,
full_name = excluded.full_name,
is_system_admin = true,
is_active = true
returning id into v_user_id;
+60 -35
View File
@@ -167,7 +167,9 @@ app.post('/auth/login', async (req, res) => {
if (!email || !password) return res.status(400).json({ error: 'email and password required' });
try {
const { rows } = await pool.query(
'SELECT id, email, password_hash, full_name, is_active FROM auth.users WHERE email = $1 LIMIT 1',
`SELECT id, email, password_hash, full_name, is_active,
coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin
FROM auth.users u WHERE email = $1 LIMIT 1`,
[String(email).trim().toLowerCase()],
);
const u = rows[0];
@@ -176,7 +178,7 @@ app.post('/auth/login', async (req, res) => {
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
await pool.query('UPDATE auth.users SET last_login_at = now() WHERE id = $1', [u.id]);
const token = signToken(u);
res.json({ token, user: { id: u.id, email: u.email, full_name: u.full_name } });
res.json({ token, user: { id: u.id, email: u.email, full_name: u.full_name, is_system_admin: u.is_system_admin } });
} catch (e) { dbError(res, e); }
});
@@ -221,7 +223,8 @@ app.post('/rpc/:fn', authRequired, async (req, res) => {
// GET /from/:view?col=val&col2=val2 -> SELECT * FROM app.<view> WHERE ...
const ALLOWED_VIEWS = new Set([
'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions',
'v_manage_tills',
'v_manage_tills', 'v_service_ui_settings',
'v_employee_outstanding_balances',
// Owner / manager dashboards (RLS still restricts rows to allowed shops):
'v_owner_dashboard', 'v_z_report', 'v_employee_scorecard_30d', 'alerts', 'v_end_of_day_reports',
]);
@@ -311,30 +314,66 @@ app.post('/employee_transactions', authRequired, async (req, res) => {
// ---- admin: user management -----------------------------------------
async function ensureAdmin(req, res) {
// Owner-anywhere == admin in the UI. Compute via app.is_owner_anywhere().
try {
const ok = await withUserClient(req, async (client) => {
const r = await client.query('SELECT app.is_owner_anywhere() AS ok');
return !!r.rows[0]?.ok;
});
if (!ok) { res.status(403).json({ error: 'admin only' }); return false; }
const { rows } = await pool.query(`
SELECT coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS ok
FROM auth.users u
WHERE u.id = $1
`, [req.user.id]);
const ok = !!rows[0]?.ok;
if (!ok) {
res.status(403).json({ error: 'admin only' });
return false;
}
return true;
} catch (e) { dbError(res, e); return false; }
}
function normalizeUserRole(role) {
if (role === 'admin' || role === 'owner' || role === 'employee') return role;
return 'employee';
}
async function firstShopId(client) {
const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1');
return shop.rows[0]?.id ?? null;
}
async function assignShopRole(client, userId, role, assignedBy, shopIdParam) {
const shopId = shopIdParam || await firstShopId(client);
if (!shopId) return;
await client.query(
`INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`,
[userId, shopId, role, assignedBy],
);
}
async function upsertEmployee(client, { empId, name, email, department }) {
if (!empId) return;
await client.query(
`INSERT INTO app.employees(emp_id, name, email, department)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department`,
[empId, name, email, department || null],
);
}
app.get('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
try {
const { rows } = await pool.query(`
SELECT u.id, u.email, u.is_active, u.full_name,
p.full_name AS profile_name,
coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin,
coalesce(
(SELECT a.role::text FROM app.user_shop_assignments a
WHERE a.user_id = u.id ORDER BY (a.role = 'owner') DESC LIMIT 1),
'cashier'
) AS shop_role,
EXISTS (SELECT 1 FROM app.user_shop_assignments a
WHERE a.user_id = u.id AND a.role = 'owner') AS is_admin,
(SELECT e.emp_id FROM app.employees e WHERE e.email = u.email LIMIT 1) AS emp_id,
(SELECT e.department FROM app.employees e WHERE e.email = u.email LIMIT 1) AS department
FROM auth.users u
@@ -347,23 +386,25 @@ app.get('/admin/users', authRequired, async (req, res) => {
app.post('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
const { email, password, name, role, department, empId } = req.body || {};
const { email, password, name, role, department, empId, shopId } = req.body || {};
if (!email || !password || !name) {
return res.status(400).json({ error: 'email, password, name required' });
}
if (String(password).length < 6) {
return res.status(400).json({ error: 'password must be at least 6 chars' });
}
const isAdmin = role === 'admin';
const userRole = normalizeUserRole(role);
const isAdmin = userRole === 'admin';
const shopRole = userRole === 'employee' ? 'cashier' : 'owner';
const client = await pool.connect();
try {
await client.query('BEGIN');
const hash = await bcrypt.hash(password, 10);
const u = await client.query(
`INSERT INTO auth.users(email, password_hash, full_name, is_active)
VALUES ($1, $2, $3, true) RETURNING id, email, full_name`,
[String(email).trim().toLowerCase(), hash, name],
`INSERT INTO auth.users(email, password_hash, full_name, is_system_admin, is_active)
VALUES ($1, $2, $3, $4, true) RETURNING id, email, full_name`,
[String(email).trim().toLowerCase(), hash, name, isAdmin],
);
const userId = u.rows[0].id;
await client.query(
@@ -372,25 +413,9 @@ app.post('/admin/users', authRequired, async (req, res) => {
ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name`,
[userId, name],
);
// Assign to the first available shop (Default Shop typically) so they have a role.
const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1');
if (shop.rows[0]) {
await client.query(
`INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`,
[userId, shop.rows[0].id, isAdmin ? 'owner' : 'cashier', req.user.id],
);
}
if (!isAdmin && empId) {
await client.query(
`INSERT INTO app.employees(emp_id, name, email, department)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department`,
[empId, name, email, department || null],
);
if (!isAdmin) {
await assignShopRole(client, userId, shopRole, req.user.id, shopId);
await upsertEmployee(client, { empId, name, email, department });
}
await client.query('COMMIT');
res.json({ data: { id: userId, email: u.rows[0].email, full_name: u.rows[0].full_name } });