diff --git a/backend/package.json b/backend/package.json
index 5c80ad8..be224db 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "rosterchirp-backend",
- "version": "0.12.11",
+ "version": "0.12.12",
"description": "RosterChirp backend server",
"main": "src/index.js",
"scripts": {
diff --git a/backend/src/models/migrations/009_user_profile_fields.sql b/backend/src/models/migrations/009_user_profile_fields.sql
new file mode 100644
index 0000000..f192ce5
--- /dev/null
+++ b/backend/src/models/migrations/009_user_profile_fields.sql
@@ -0,0 +1,17 @@
+-- Migration 009: Extended user profile fields
+ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS is_minor BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Back-fill first_name / last_name from existing combined name for non-deleted users
+UPDATE users
+SET
+ first_name = SPLIT_PART(TRIM(name), ' ', 1),
+ last_name = CASE
+ WHEN POSITION(' ' IN TRIM(name)) > 0
+ THEN NULLIF(TRIM(SUBSTR(TRIM(name), POSITION(' ' IN TRIM(name)) + 1)), '')
+ ELSE NULL
+ END
+WHERE first_name IS NULL
+ AND TRIM(name) NOT IN ('Deleted User', '');
diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js
index d9c2d26..2fc4b56 100644
--- a/backend/src/routes/users.js
+++ b/backend/src/routes/users.js
@@ -33,7 +33,7 @@ function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const users = await query(req.schema,
- "SELECT id,name,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC"
+ "SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
);
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
@@ -82,26 +82,66 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
// Create user
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
- const { name, email, password, role } = req.body;
- if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
- if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
+ const { firstName, lastName, email, password, role, phone, isMinor } = req.body;
+ if (!firstName?.trim() || !lastName?.trim() || !email)
+ return res.status(400).json({ error: 'First name, last name and email required' });
+ if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
+ const validRoles = ['member', 'admin', 'manager'];
+ const assignedRole = validRoles.includes(role) ? role : 'member';
+ const name = `${firstName.trim()} ${lastName.trim()}`;
try {
- const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email = $1 AND status != 'deleted'", [email]);
+ const exists = await queryOne(req.schema, "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]);
if (exists) return res.status(400).json({ error: 'Email already in use' });
- const resolvedName = await resolveUniqueName(req.schema, name.trim());
+ const resolvedName = await resolveUniqueName(req.schema, name);
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
const hash = bcrypt.hashSync(pw, 10);
const r = await queryResult(req.schema,
- "INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
- [resolvedName, email, hash, role === 'admin' ? 'admin' : 'member']
+ "INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',TRUE) RETURNING id",
+ [resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, !!isMinor]
);
const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId);
- if (role === 'admin') {
+ if (assignedRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
}
- const user = await queryOne(req.schema, 'SELECT id,name,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
+ const user = await queryOne(req.schema, 'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
+ res.json({ user });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Update user (general — name components, phone, is_minor, role, optional password reset)
+router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
+ const { firstName, lastName, phone, isMinor, role, password } = req.body;
+ if (!firstName?.trim() || !lastName?.trim())
+ return res.status(400).json({ error: 'First and last name required' });
+ const validRoles = ['member', 'admin', 'manager'];
+ if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' });
+ try {
+ const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
+ if (!target) return res.status(404).json({ error: 'User not found' });
+ if (target.is_default_admin && role !== 'admin')
+ return res.status(403).json({ error: 'Cannot change default admin role' });
+ const name = `${firstName.trim()} ${lastName.trim()}`;
+ const resolvedName = await resolveUniqueName(req.schema, name, id);
+ await exec(req.schema,
+ 'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,role=$6,updated_at=NOW() WHERE id=$7',
+ [resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, !!isMinor, role, id]
+ );
+ if (password && password.length >= 6) {
+ const hash = bcrypt.hashSync(password, 10);
+ await exec(req.schema, 'UPDATE users SET password=$1,must_change_password=TRUE,updated_at=NOW() WHERE id=$2', [hash, id]);
+ }
+ if (role === 'admin' && target.role !== 'admin') {
+ const sgId = await getOrCreateSupportGroup(req.schema);
+ if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]);
+ }
+ const user = await queryOne(req.schema,
+ 'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
+ [id]
+ );
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -159,7 +199,7 @@ router.patch('/:id/name', authMiddleware, teamManagerMiddleware, async (req, res
// Patch role
router.patch('/:id/role', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { role } = req.body;
- if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
+ if (!['member','admin','manager'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
try {
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!target) return res.status(404).json({ error: 'User not found' });
@@ -216,6 +256,10 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req,
status = 'deleted',
email = $1,
name = 'Deleted User',
+ first_name = NULL,
+ last_name = NULL,
+ phone = NULL,
+ is_minor = FALSE,
display_name = NULL,
avatar = NULL,
about_me = NULL,
diff --git a/build.sh b/build.sh
index 230aaa9..a900a30 100644
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
-VERSION="${1:-0.12.11}"
+VERSION="${1:-0.12.12}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp"
diff --git a/frontend/package.json b/frontend/package.json
index 80e3eac..6f6cba7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
- "version": "0.12.11",
+ "version": "0.12.12",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index dd4e26a..f817a8f 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -194,6 +194,7 @@ a { color: inherit; text-decoration: none; }
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase;
}
.role-admin { background: #fce8e6; color: #c5221f; }
+.role-manager { background: #e6f4ea; color: #1e7e34; }
.role-member { background: var(--primary-light); color: var(--primary); }
.status-suspended { background: #fff3e0; color: #e65100; }
diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx
index 42824ba..b9a78a1 100644
--- a/frontend/src/pages/UserManagerPage.jsx
+++ b/frontend/src/pages/UserManagerPage.jsx
@@ -3,10 +3,16 @@ import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import Avatar from '../components/Avatar.jsx';
import UserFooter from '../components/UserFooter.jsx';
+import PasswordInput from '../components/PasswordInput.jsx';
const SIDEBAR_W = 320;
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
+function isValidPhone(p) {
+ if (!p || !p.trim()) return true;
+ const digits = p.replace(/[\s\-\(\)\+\.x#]/g, '');
+ return /^\d{7,15}$/.test(digits);
+}
function parseCSV(text) {
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
@@ -24,38 +30,11 @@ function parseCSV(text) {
return { rows, invalid };
}
-function UserRow({ u, onUpdated }) {
+// ── User Row (accordion list item) ───────────────────────────────────────────
+function UserRow({ u, onUpdated, onEdit }) {
const toast = useToast();
const [open, setOpen] = useState(false);
- const [resetPw, setResetPw] = useState('');
- const [showReset, setShowReset] = useState(false);
- const [editName, setEditName] = useState(false);
- const [nameVal, setNameVal] = useState(u.name);
- const [roleWarning, setRoleWarning] = useState(false);
- // onIF/onIB are no-ops here — UserRow doesn't have access to the page-level
- // inputFocused state. The mobile footer is controlled by the parent page only.
- const onIF = () => {};
- const onIB = () => {};
- const handleRole = async (role) => {
- if (!role) { setRoleWarning(true); return; }
- setRoleWarning(false);
- try { await api.updateRole(u.id, role); toast('Role updated', 'success'); onUpdated(); }
- catch (e) { toast(e.message, 'error'); }
- };
- const handleResetPw = async () => {
- if (!resetPw || resetPw.length < 6) return toast('Min 6 characters', 'error');
- try { await api.resetPassword(u.id, resetPw); toast('Password reset', 'success'); setShowReset(false); setResetPw(''); onUpdated(); }
- catch (e) { toast(e.message, 'error'); }
- };
- const handleSaveName = async () => {
- if (!nameVal.trim()) return toast('Name cannot be empty', 'error');
- try {
- const { name } = await api.updateName(u.id, nameVal.trim());
- toast(name !== nameVal.trim() ? `Saved as "${name}"` : 'Name updated', 'success');
- setEditName(false); onUpdated();
- } catch (e) { toast(e.message, 'error'); }
- };
const handleSuspend = async () => {
if (!confirm(`Suspend ${u.name}?`)) return;
try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); }
@@ -74,7 +53,7 @@ function UserRow({ u, onUpdated }) {
return (
-
{u.email}
-
- Last online: {(() => {
- if (!u.last_online) return 'Never';
- const d = new Date(u.last_online); const today = new Date(); today.setHours(0,0,0,0);
- const yesterday = new Date(today); yesterday.setDate(yesterday.getDate()-1);
- const dd = new Date(d); dd.setHours(0,0,0,0);
- if (dd >= today) return 'Today'; if (dd >= yesterday) return 'Yesterday';
- return dd.toISOString().slice(0,10);
- })()}
-
- {!!u.must_change_password && ⚠ Must change password
}