From c232d00d9a9154be4bd018fd452a4f0962b56cba Mon Sep 17 00:00:00 2001 From: antanst <> Date: Tue, 12 Aug 2025 20:38:10 +0300 Subject: [PATCH] Admin user management: backend API and frontend UI. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add admin-only users API: list/create/delete (prevent self-delete and last-admin deletion). - Include is_admin in auth responses. - Frontend: /admin/users page with table, selection, remove, Add User modal. - Show “Manage users” in user menu for admins and optional sidebar link. - Add i18n strings for admin UI. - Enhance create user script to grant admin via optional third arg. - Minor: set dev bootstrap user as admin in start script. --- backend/cmd/start.sh | 2 +- backend/routes/admin.js | 96 +++++++ backend/routes/auth.js | 5 + backend/scripts/user-create.js | 21 +- docs/architecture-auth-permissions.md | 55 ++++ frontend/App.tsx | 17 ++ frontend/components/Admin/AdminUsersPage.tsx | 275 +++++++++++++++++++ frontend/components/Navbar.tsx | 9 + frontend/components/Sidebar/SidebarNav.tsx | 13 + frontend/entities/User.ts | 1 + planning-sharing-permissions.md | 159 +++++++++++ public/locales/en/translation.json | 15 + 12 files changed, 663 insertions(+), 5 deletions(-) create mode 100644 docs/architecture-auth-permissions.md create mode 100644 frontend/components/Admin/AdminUsersPage.tsx create mode 100644 planning-sharing-permissions.md diff --git a/backend/cmd/start.sh b/backend/cmd/start.sh index 671952c..acfd884 100755 --- a/backend/cmd/start.sh +++ b/backend/cmd/start.sh @@ -92,7 +92,7 @@ else fi if [ -n "${TUDUDI_USER_EMAIL:-}" ] && [ -n "${TUDUDI_USER_PASSWORD:-}" ]; then - node scripts/user-create.js "$TUDUDI_USER_EMAIL" "$TUDUDI_USER_PASSWORD" || exit 1 + node scripts/user-create.js "$TUDUDI_USER_EMAIL" "$TUDUDI_USER_PASSWORD" true || exit 1 fi exec node app.js diff --git a/backend/routes/admin.js b/backend/routes/admin.js index d67346b..cfab606 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -38,3 +38,99 @@ router.post('/admin/set-admin-role', async (req, res) => { }); module.exports = router; + +// --- Admin Users Management --- +// NOTE: app.js already mounts this router under requireAuth + +// Middleware to ensure admin access +async function requireAdmin(req, res, next) { + try { + const requesterId = req.currentUser?.id || req.session?.userId; + if (!requesterId) return res.status(401).json({ error: 'Authentication required' }); + const admin = await isAdmin(requesterId); + if (!admin) return res.status(403).json({ error: 'Forbidden' }); + next(); + } catch (err) { + next(err); + } +} + +// GET /api/admin/users - list users with role and creation date +router.get('/admin/users', requireAdmin, async (req, res) => { + try { + const users = await User.findAll({ attributes: ['id', 'email', 'created_at'] }); + // Fetch roles in bulk + const roles = await Role.findAll({ attributes: ['user_id', 'is_admin'] }); + const userIdToRole = new Map(roles.map(r => [r.user_id, r.is_admin])); + const result = users.map(u => ({ + id: u.id, + email: u.email, + created_at: u.created_at, + role: userIdToRole.get(u.id) ? 'admin' : 'user', + })); + res.json(result); + } catch (err) { + console.error('Error listing users:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// POST /api/admin/users - create a user (default role: user) +router.post('/admin/users', requireAdmin, async (req, res) => { + try { + const { email, password, role } = req.body || {}; + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + // Very basic validation consistent with login rules + if (typeof email !== 'string' || !email.includes('@')) { + return res.status(400).json({ error: 'Invalid email' }); + } + if (typeof password !== 'string' || password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + // Create user; model hook will hash password + const user = await User.create({ email, password }); + // Optionally assign admin role if requested and allowed + const makeAdmin = role === 'admin'; + if (makeAdmin) { + await Role.findOrCreate({ where: { user_id: user.id }, defaults: { user_id: user.id, is_admin: true } }); + } + res.status(201).json({ id: user.id, email: user.email, created_at: user.created_at, role: makeAdmin ? 'admin' : 'user' }); + } catch (err) { + console.error('Error creating user:', err); + // Unique constraint + if (err?.name === 'SequelizeUniqueConstraintError') { + return res.status(409).json({ error: 'Email already exists' }); + } + res.status(400).json({ error: 'There was a problem creating the user.' }); + } +}); + +// DELETE /api/admin/users/:id - delete a user, prevent self-delete +router.delete('/admin/users/:id', requireAdmin, async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const requesterId = req.currentUser?.id || req.session?.userId; + if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid user id' }); + if (id === requesterId) return res.status(400).json({ error: 'Cannot delete your own account' }); + + const user = await User.findByPk(id); + if (!user) return res.status(404).json({ error: 'User not found' }); + + // Prevent deleting the last remaining admin + const targetRole = await Role.findOne({ where: { user_id: id } }); + if (targetRole?.is_admin) { + const adminCount = await Role.count({ where: { is_admin: true } }); + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot delete the last remaining admin' }); + } + } + + await user.destroy(); + res.status(204).send(); + } catch (err) { + console.error('Error deleting user:', err); + res.status(400).json({ error: 'There was a problem deleting the user.' }); + } +}); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 1a231d6..f20330d 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,5 +1,6 @@ const express = require('express'); const { User } = require('../models'); +const { isAdmin } = require('../services/rolesService'); const packageJson = require('../../package.json'); const router = express.Router(); @@ -14,6 +15,7 @@ router.get('/current_user', async (req, res) => { if (req.session && req.session.userId) { const user = await User.findByPk(req.session.userId); if (user) { + const admin = await isAdmin(user.id); return res.json({ user: { id: user.id, @@ -21,6 +23,7 @@ router.get('/current_user', async (req, res) => { language: user.language, appearance: user.appearance, timezone: user.timezone, + is_admin: admin, }, }); } @@ -64,6 +67,7 @@ router.post('/login', async (req, res) => { }); }); + const admin = await isAdmin(user.id); res.json({ user: { id: user.id, @@ -71,6 +75,7 @@ router.post('/login', async (req, res) => { language: user.language, appearance: user.appearance, timezone: user.timezone, + is_admin: admin, }, }); } catch (error) { diff --git a/backend/scripts/user-create.js b/backend/scripts/user-create.js index 765de2c..7ed0ec4 100755 --- a/backend/scripts/user-create.js +++ b/backend/scripts/user-create.js @@ -4,7 +4,7 @@ * User Creation Script * Creates a new user with email and password. * If user exists, updated password. - * Usage: node user-create.js + * Usage: node user-create.js [is_admin] */ require('dotenv').config(); @@ -13,14 +13,15 @@ const { validateEmail, validatePassword, } = require('../services/userService'); +const { Role } = require('../models'); async function createUser() { - const [email, password] = process.argv.slice(2); + const [email, password, isAdminArg] = process.argv.slice(2); if (!email || password === undefined) { - console.error('Usage: npm run user:create '); + console.error('Usage: npm run user:create [is_admin]'); console.error( - 'Example: npm run user:create admin@example.com mypassword123' + 'Example: npm run user:create admin@example.com mypassword123 true' ); process.exit(1); } @@ -42,6 +43,15 @@ async function createUser() { const { user, created } = await createOrUpdateUser(email, password); + // Optionally grant admin role + const shouldBeAdmin = String(isAdminArg).toLowerCase() === 'true'; + if (shouldBeAdmin) { + await Role.findOrCreate({ + where: { user_id: user.id }, + defaults: { user_id: user.id, is_admin: true }, + }); + } + if (!created) { console.log('User exists, password updated'); } else { @@ -51,6 +61,9 @@ async function createUser() { console.log(`Email: ${user.email}`); console.log(`User ID: ${user.id}`); console.log(`Created: ${user.created_at}`); + if (isAdminArg !== undefined) { + console.log(`Admin: ${shouldBeAdmin ? 'yes' : 'no'}`); + } process.exit(0); } catch (error) { diff --git a/docs/architecture-auth-permissions.md b/docs/architecture-auth-permissions.md new file mode 100644 index 0000000..afdbcfd --- /dev/null +++ b/docs/architecture-auth-permissions.md @@ -0,0 +1,55 @@ +# Authentication, Permissions, and Sharing Architecture + +This document outlines the backend security model for authentication, authorization (RBAC), and resource sharing. + +## Authentication +- Session-based auth with `express-session` and Sequelize store (`backend/app.js`). +- Middleware `requireAuth` (`backend/middleware/auth.js`) guards all API routes under `/api` except health, login, and `current_user`. + +## Resource Identity +- Core resources: `project`, `task`, `note`. +- Each resource has a stable `uid` used for sharing/access decisions. Some routes accept numeric IDs but resolve to `uid` internally. + +## Authorization (RBAC) +- Access levels: `none`, `ro`, `rw`, `admin` (`permissionsService.ACCESS`). +- Ownership implies `rw` access. Admin role implies `admin` access (`backend/services/rolesService.js`). +- Central check: `hasAccess(requiredAccess, resourceType, getResourceUid, options)` (`backend/middleware/authorize.js`). + - Resolves `uid` via `getResourceUid(req)`. + - Calls `permissionsService.getAccess(userId, resourceType, uid)`. + - Compares levels and either `next()` or returns 403/404 based on options. + +### Permissions Service +- `getAccess(userId, resourceType, uid)`: + - If admin → `admin`. + - If owner (via model lookup) → `rw`. + - Else checks `permissions` table for shared access level. +- `ownershipOrPermissionWhere(resourceType, userId)`: + - Returns a Sequelize `where` clause that matches owned resources or resources shared to the user (by `uid`). Useful for list endpoints. + +## Sharing Model +- Stored in `permissions` table (`backend/models/permission.js`): + - Columns: `user_id`, `resource_type`, `resource_uid`, `access_level` (ro/rw/admin), `propagation` (direct/inherited), `granted_by_user_id`. +- Propagation rules computed by calculators (`backend/services/permissionsCalculators.js`): + - Sharing a `project` propagates to descendant `tasks` and `notes` (inherited). + - Sharing a `task` can propagate to its descendants. + - Sharing a `note` applies directly. + - Revokes remove the corresponding rows (and inherited ones). + +## HTTP Status Semantics +- 401 Unauthorized: no session or invalid session. +- 404 Not Found: resource does not exist (or explicitly configured legacy concealment). +- 403 Forbidden: resource exists but caller lacks required access. + +## Route Usage Patterns +- Read collection endpoints (e.g., `/api/notes`, `/api/tasks`, `/api/projects`): + - Use `ownershipOrPermissionWhere` to include owned and shared resources. +- Item endpoints (e.g., `/api/note/:id`, `/api/task/:id`): + - Wrap handlers with `hasAccess('ro'|'rw', resourceType, getResourceUid, { notFoundMessage })`. + - Inside the handler, assume access is enforced; still handle true 404s when the item is deleted between checks. + +## Testing Guarantees +- Integration tests assert: + - 401 for unauthenticated requests. + - 404 for non-existent resources. + - 403 for existing resources without sufficient permissions. + - Project UID-slug routes correctly return 403 when the project exists but is not accessible. diff --git a/frontend/App.tsx b/frontend/App.tsx index c22d19a..7a133fa 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -52,8 +52,10 @@ const App: React.FC = () => { const data = await response.json(); if (data.user) { setCurrentUser(data.user); + (window as any).__CURRENT_USER__ = data.user; } else { setCurrentUser(null); + (window as any).__CURRENT_USER__ = null; } } catch { setCurrentUser(null); @@ -72,6 +74,7 @@ const App: React.FC = () => { const handleUserLoggedIn = (event: CustomEvent) => { const user = event.detail; setCurrentUser(user); + (window as any).__CURRENT_USER__ = user; }; window.addEventListener( @@ -243,6 +246,20 @@ const App: React.FC = () => { } /> } /> + Loading...}> + {React.createElement( + React.lazy(() => import('./components/Admin/AdminUsersPage')) + )} + + ) : ( + + ) + } + /> } /> diff --git a/frontend/components/Admin/AdminUsersPage.tsx b/frontend/components/Admin/AdminUsersPage.tsx new file mode 100644 index 0000000..530a514 --- /dev/null +++ b/frontend/components/Admin/AdminUsersPage.tsx @@ -0,0 +1,275 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { UserPlusIcon, TrashIcon } from '@heroicons/react/24/outline'; + +interface AdminUserItem { + id: number; + email: string; + created_at: string; + role: 'admin' | 'user'; +} + +const fetchAdminUsers = async (): Promise => { + const res = await fetch('/api/admin/users', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (res.status === 401) throw new Error('Authentication required'); + if (res.status === 403) throw new Error('Forbidden'); + if (!res.ok) throw new Error('Failed to load users'); + return await res.json(); +}; + +const createAdminUser = async (email: string, password: string, role?: 'admin' | 'user'): Promise => { + const res = await fetch('/api/admin/users', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ email, password, role }), + }); + if (res.status === 401) throw new Error('Authentication required'); + if (res.status === 403) throw new Error('Forbidden'); + if (res.status === 409) throw new Error('Email already exists'); + if (!res.ok) { + let message = 'Failed to create user'; + try { + const body = await res.json(); + if (body?.error) message = body.error; + } catch {} + throw new Error(message); + } + return await res.json(); +}; + +const deleteAdminUser = async (id: number): Promise => { + const res = await fetch(`/api/admin/users/${id}`, { + method: 'DELETE', + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (res.status === 401) throw new Error('Authentication required'); + if (res.status === 403) throw new Error('Forbidden'); + if (res.status === 400) { + const body = await res.json().catch(() => ({ error: 'Bad request' })); + throw new Error(body.error || 'Bad request'); + } + if (res.status === 404) throw new Error('User not found'); + if (!res.ok && res.status !== 204) throw new Error('Failed to delete user'); +}; + +const AddUserModal: React.FC<{ + isOpen: boolean; + onClose: () => void; + onCreated: (user: AdminUserItem) => void; +}> = ({ isOpen, onClose, onCreated }) => { + const { t } = useTranslation(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState<'user' | 'admin'>('user'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const isValidEmail = (value: string) => { + // Simple email format validation + return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value); + }; + + useEffect(() => { + if (isOpen) { + setEmail(''); + setPassword(''); + setRole('user'); + setError(null); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (!email || !password) { + setError(t('errors.required', 'This field is required')); + return; + } + if (!isValidEmail(email)) { + setError(t('errors.invalidEmail', 'Invalid email address')); + return; + } + setSubmitting(true); + try { + const user = await createAdminUser(email, password, role); + onCreated(user); + onClose(); + } catch (err: any) { + setError(err.message || 'Failed to create user'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
e.stopPropagation()}> +

{t('admin.addUser', 'Add user')}

+
+
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required minLength={6} /> +
+
+ + +
+ {error &&
{error}
} +
+ + +
+
+
+
+ ); +}; + +const AdminUsersPage: React.FC = () => { + const { t } = useTranslation(); + const [users, setUsers] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [addOpen, setAddOpen] = useState(false); + const navigate = useNavigate(); + + const selectedCount = selectedIds.size; + + const load = async () => { + setLoading(true); + setError(null); + try { + const data = await fetchAdminUsers(); + setUsers(data); + } catch (err: any) { + setError(err.message || 'Failed to load users'); + if (err.message === 'Forbidden') navigate('/today'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, []); + + const toggleSelect = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (!users) return; + setSelectedIds((prev) => { + if (prev.size === users.length) return new Set(); + return new Set(users.map((u) => u.id)); + }); + }; + + const removeSelected = async () => { + if (!users || selectedIds.size === 0) return; + const toDelete = Array.from(selectedIds); + const remaining: AdminUserItem[] = []; + const byId = new Map(users.map((u) => [u.id, u] as const)); + for (const id of toDelete) { + try { + await deleteAdminUser(id); + } catch (err: any) { + // Keep the user if deletion failed and surface error inline later + console.error('Failed to delete user', id, err?.message); + remaining.push(byId.get(id)!); + } + } + const next = users.filter((u) => !toDelete.includes(u.id)); + // If any failed, keep them + const nextWithFailures = remaining.length + ? next.concat(remaining.filter((r) => !next.find((n) => n.id === r.id))) + : next; + setUsers(nextWithFailures); + setSelectedIds(new Set()); + }; + + const headerCheckboxChecked = useMemo(() => { + if (!users || users.length === 0) return false; + return selectedIds.size === users.length; + }, [users, selectedIds]); + + return ( +
+
+

{t('admin.userManagement', 'User Management')}

+
+ + +
+
+ + {error && ( +
{error}
+ )} + +
+ + + + + + + + + + + {loading && ( + + )} + {!loading && users && users.length === 0 && ( + + )} + {!loading && users && users.map((u) => ( + + + + + + + ))} + +
+ + {t('admin.email', 'Email')}{t('admin.created', 'Created')}{t('admin.role', 'Role')}
{t('admin.loadingUsers', 'Loading users...')}
{t('admin.noUsers', 'No users')}
+ toggleSelect(u.id)} /> + {u.email}{new Date(u.created_at).toLocaleString()} + + {u.role === 'admin' ? t('admin.admin', 'admin') : t('admin.user', 'user')} + +
+
+ + setAddOpen(false)} onCreated={(user) => setUsers((prev) => (prev ? [user, ...prev] : [user]))} /> +
+ ); +}; + +export default AdminUsersPage; diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index 9ac1a2f..86efa51 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -196,6 +196,15 @@ const Navbar: React.FC = ({ 'Profile Settings' )} + {(window as any).__CURRENT_USER__?.is_admin && ( + setIsDropdownOpen(false)} + > + {t('admin.manageUsers', 'Manage users')} + + )} = ({ }, ]; + // Append admin link if current user is admin (read from global window state via current_user fetch in App) + try { + const raw = (window as any).__CURRENT_USER__; + if (raw?.is_admin) { + navLinks.push({ + path: '/admin/users', + title: t('sidebar.adminUsers', 'User Management'), + icon: , + } as any); + } + } catch {} + const isActive = (path: string, query?: string) => { // Handle special case for paths without query parameters if (path === '/inbox' || path === '/today' || path === '/upcoming') { diff --git a/frontend/entities/User.ts b/frontend/entities/User.ts index a9c607e..cbb43a8 100644 --- a/frontend/entities/User.ts +++ b/frontend/entities/User.ts @@ -5,4 +5,5 @@ export interface User { appearance: string; timezone: string; avatarUrl?: string; + is_admin?: boolean; } diff --git a/planning-sharing-permissions.md b/planning-sharing-permissions.md new file mode 100644 index 0000000..b5391af --- /dev/null +++ b/planning-sharing-permissions.md @@ -0,0 +1,159 @@ +### Goal +Introduce sharing and permissions as an additive layer, starting with project sharing (cascade to its items), without changing existing item schemas. Fast reads via a precomputed permissions table; full audit via actions. + +### Data model (new tables) +- **Permissions (`permissions`)** + - id, user_id, resource_type ('area'|'project'|'task'|'note'|'tag'), resource_uid, access_level ('ro'|'rw'), propagation ('direct'|'inherited'), granted_by_user_id, source_action_id (nullable), created_at + - Unique index: (user_id, resource_type, resource_uid) + - Indexes: (resource_type, resource_uid), (user_id), (access_level) +- **Actions (`actions`)** + - id, actor_user_id, verb ('share_grant'|'share_revoke'|...), resource_type, resource_uid, target_user_id, access_level, metadata JSON, created_at + - Indexes: (resource_type, resource_uid), (target_user_id) +- **Users** + - Add `is_admin` boolean (default false), indexed + +Notes +- Use `resource_uid` for item identity (all core models have `uid`). +- Ownership is derived from the model's `user_id` (no owner row stored in `permissions`). +- Admin bypasses `permissions` checks (rule-level, not stored). + +### Services +- **PermissionsService** + - `grantShare(actorUserId, resourceType, resourceUid, targetUserId, accessLevel)` + - `revokeShare(actorUserId, resourceType, resourceUid, targetUserId)` + - `getAccess(userId, resourceType, resourceUid)` → 'none'|'ro'|'rw'|'admin' (ownership checked via `user_id` on the resource) + - `rebuildForResource(resourceType, resourceUid)` → recompute cascade rows + - `rebuildForUser(userId)` and full rebuild (maintenance) +- **ResourceTraversal** + - `descendants(resourceType, resourceUid)` → list of {resource_type, resource_uid} + - Initial support: + - project → tasks (and all their subtasks via parent chain), notes + - area → projects → tasks, notes + - Extensible map for future nested projects or new item types +- **ActionsLog** + - `append(action)` for share grant/revoke and seeds + - Audit queries by resource or user + +### Enforcement +- Rule evaluation + - Admin: full rw everywhere + - Otherwise: max(access from `permissions`) for (user, resource) +- Query helpers + - `ownershipOrPermissionWhere(resourceType, userId)` returns: + - (table.user_id = :userId) OR EXISTS ( + SELECT 1 FROM permissions p + WHERE p.resource_type = :type AND p.resource_uid = table.uid AND p.user_id = :userId + ) + - Access checks for mutating routes must require 'rw' or ownership/admin + +### Endpoints (new) +- POST `/api/shares` { resource_type, resource_uid, target_user_email, access_level } +- DELETE `/api/shares` { resource_type, resource_uid, target_user_id } +- GET `/api/shares` { resource_type, resource_uid } → current shares +- GET `/api/permissions/preview` { resource_type, resource_uid } → debug-only (dev) + +Constraints +- Only owners and admins can share (owners limited to their items). +- Share mode: 'ro' or 'rw'. +- Cascade on share/revoke: apply to descendants; maintain propagation='inherited'. + +### Rollout plan (incremental) +1) Schema +- Add `is_admin` to `users` +- Create `actions`, `permissions` (+ indexes) + +2) Seed +- No owner seeding; permissions contain only explicit shares + +3) Sharing API +- Implement shares endpoints + audit logging + cascade calc for projects + +4) Permission-aware reads (phase 1) +- Projects read endpoints include shared: + - use `ownershipOrPermissionWhere('project', userId)` +- Tasks/Notes by project include shared via project cascade +- Mutations enforce 'rw' via `PermissionsService.getAccess` + +5) Permission-aware reads (phase 2) +- Global lists (All Tasks/Notes/Tags/Areas): include shared via EXISTS on `permissions` + +6) Frontend (minimal) +- On project detail: “Share” dialog (email + RO/RW), list current shares, revoke/change +- Badge/indicator when viewing shared resources +- Error toasts on insufficient permission + +7) Maintenance/ops +- Admin tools: rebuild user/resource permissions +- Feature flag `PERMISSIONS_ENABLED` to gate new behavior during rollout + +### Audit trail +- All share grants/revokes are appended to `actions` +- `source_action_id` stored in derived `permissions` rows for traceability + +### Performance and indexing +- Use `uid` joins for permission EXISTS checks +- Ensure (resource_type, resource_uid) index; and (user_id, resource_type, resource_uid) unique +- Keep cascade recomputation scoped to affected subtree + +### Edge cases +- Demotion RW→RO: update direct permission; recompute inherited leaves +- Revoke: remove direct and inherited rows for target user under subtree +- Multiple grants: keep highest effective access +- Ownership transfer (future): update the resource `user_id`; permissions unaffected except potential cascade recalculation for descendants + +### Permissions calculation architecture (functions) +- Well-defined, reusable calculators that compute diffs only (no DB I/O): + - `calculateProjectPerms`, `calculateTaskPerms`, `calculateNotePerms`, `calculateAreaPerms`, `calculateTagPerms` + - Project/Area calculators must reuse `calculateTaskPerms` / `calculateNotePerms` to cover descendants; subtasks are handled by `calculateTaskPerms` (no separate function). +- Application layer: + - `applyPerms(tx, changes)` applies diffs (bulk upsert/delete) inside a DB transaction. + - `execAction(db, action)` validates, opens a transaction, calls the appropriate calculators, calls `applyPerms`, persists the `actions` row, and commits. + +Suggested types (pseudo-TS): + +```ts +type ResourceType = 'area'|'project'|'task'|'note'|'tag'; +type AccessLevel = 'ro'|'rw'; +type Propagation = 'direct'|'inherited'; + +type PermUpsert = { + userId: number; + resourceType: ResourceType; + resourceUid: string; + accessLevel: AccessLevel; + propagation: Propagation; + grantedByUserId: number; + sourceActionId?: number; // set by execAction +}; + +type PermDelete = { + userId: number; + resourceType: ResourceType; + resourceUid: string; +}; + +type PermChanges = { upserts: PermUpsert[]; deletes: PermDelete[] }; + +type ShareAction = + | { verb: 'share_grant'; actorUserId: number; targetUserId: number; resourceType: ResourceType; resourceUid: string; accessLevel: AccessLevel } + | { verb: 'share_revoke'; actorUserId: number; targetUserId: number; resourceType: ResourceType; resourceUid: string }; + +async function calculateProjectPerms(ctx, input): Promise {} +async function calculateTaskPerms(ctx, input): Promise {} +async function calculateNotePerms(ctx, input): Promise {} +async function calculateAreaPerms(ctx, input): Promise {} +async function calculateTagPerms(ctx, input): Promise {} + +async function applyPerms(tx, changes: PermChanges): Promise {} +async function execAction(db, action: ShareAction): Promise {} +``` + +Key behaviors +- Ownership/admin: ownership derived from `user_id`; admins bypass checks. `execAction` enforces "only owner or admin can share". +- Subtree reuse: higher-level calculators compose results by calling lower-level ones (e.g., project → tasks/notes; tasks include subtasks). +- Diff rules: return only necessary changes; dedupe per (user,type,uid); collapse to max access (rw > ro); demotion RW→RO produces an upsert; revoke produces deletes (direct + inherited under subtree). +- Idempotency: `applyPerms` uses upsert (ON CONFLICT DO UPDATE) and keeps higher access; `execAction` may accept an idempotency key to avoid double application. +- Transactions: `execAction` inserts action, computes, sets `sourceActionId` on upserts, then `applyPerms`, all within a single transaction. +- Performance: batch-fetch subtree UIDs; bulk upsert/delete. +- Testing: unit-test calculators (pure); integration-test `execAction` with DB. +- Observability: `actions` row is the audit source of truth. diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index f3cecf4..6b81eb1 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -916,4 +916,19 @@ "license": "Licensed for personal use", "builtBy": "Built by" } + , + "admin": { + "manageUsers": "Manage users", + "userManagement": "User Management", + "addUser": "Add user", + "remove": "Remove", + "email": "Email", + "created": "Created", + "role": "Role", + "loadingUsers": "Loading users...", + "noUsers": "No users", + "admin": "admin", + "user": "user", + "password": "Password" + } }