v0.12.49 family rules update
This commit is contained in:
16
backend/src/models/migrations/016_guardian_partners.sql
Normal file
16
backend/src/models/migrations/016_guardian_partners.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 016_guardian_partners.sql
|
||||
-- Partner/spouse relationship between guardians.
|
||||
-- Partners share the same child alias list (both can manage it) and can
|
||||
-- respond to events on behalf of each other within shared user groups.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guardian_partners (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id_1 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id_2 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id_1, user_id_2),
|
||||
CHECK (user_id_1 < user_id_2)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user1 ON guardian_partners(user_id_1);
|
||||
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user2 ON guardian_partners(user_id_2);
|
||||
@@ -48,6 +48,14 @@ async function postEventNotification(schema, eventId, actorId) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getPartnerId(schema, userId) {
|
||||
const row = await queryOne(schema,
|
||||
'SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END AS partner_id FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1',
|
||||
[userId]
|
||||
);
|
||||
return row?.partner_id || null;
|
||||
}
|
||||
|
||||
async function isToolManagerFn(schema, user) {
|
||||
if (user.role === 'admin' || user.role === 'manager') return true;
|
||||
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
|
||||
@@ -73,7 +81,25 @@ async function canViewEvent(schema, event, userId, isToolManager) {
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, userId]);
|
||||
return !!aliasAssigned;
|
||||
if (aliasAssigned) return true;
|
||||
// Allow if partner is assigned to the event (directly or via alias)
|
||||
const partnerId = await getPartnerId(schema, userId);
|
||||
if (partnerId) {
|
||||
const partnerAssigned = await queryOne(schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, partnerId]);
|
||||
if (partnerAssigned) return true;
|
||||
const partnerAliasAssigned = await queryOne(schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, partnerId]);
|
||||
if (partnerAliasAssigned) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function enrichEvent(schema, event) {
|
||||
@@ -243,6 +269,7 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
|
||||
await enrichEvent(req.schema, event);
|
||||
const partnerId = await getPartnerId(req.schema, req.user.id);
|
||||
const isMember = !itm && !!(
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
@@ -257,6 +284,22 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, req.user.id]))
|
||||
||
|
||||
// Partner is assigned to this event (user group or alias)
|
||||
(partnerId && !!(
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, partnerId]))
|
||||
||
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, partnerId]))
|
||||
))
|
||||
);
|
||||
if (event.track_availability && (itm || isMember)) {
|
||||
// User responses
|
||||
@@ -274,7 +317,13 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
||||
// For non-tool-managers: mask notes on entries that don't belong to them or their aliases
|
||||
if (!itm) {
|
||||
const myAliasIds = new Set(
|
||||
(await query(req.schema, 'SELECT id FROM guardian_aliases WHERE guardian_id=$1', [req.user.id])).map(r => r.id)
|
||||
(await query(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE guardian_id=$1
|
||||
OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||
)`,
|
||||
[req.user.id])).map(r => r.id)
|
||||
);
|
||||
event.availability = event.availability.map(r => {
|
||||
const isOwn = !r.is_alias && r.user_id === req.user.id;
|
||||
@@ -318,12 +367,22 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
||||
const guardiansRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_guardians_group_id'");
|
||||
const guardiansGroupId = parseInt(guardiansRow?.value);
|
||||
event.in_guardians_group = !!(guardiansGroupId && event.user_groups?.some(g => g.id === guardiansGroupId) &&
|
||||
(await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id])));
|
||||
(
|
||||
(await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id]))
|
||||
||
|
||||
(partnerId && await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, partnerId]))
|
||||
));
|
||||
|
||||
// Return current user's aliases for the responder dropdown (Guardian Only)
|
||||
// Return current user's aliases (and partner's) for the responder dropdown (Guardian Only)
|
||||
if (event.has_players_group) {
|
||||
event.my_aliases = await query(req.schema,
|
||||
'SELECT id,first_name,last_name,avatar FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name',
|
||||
`SELECT id,first_name,last_name,avatar FROM guardian_aliases
|
||||
WHERE guardian_id=$1
|
||||
OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||
)
|
||||
ORDER BY first_name,last_name`,
|
||||
[req.user.id]
|
||||
);
|
||||
}
|
||||
@@ -638,20 +697,28 @@ router.put('/:id/availability', authMiddleware, async (req, res) => {
|
||||
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
|
||||
|
||||
if (aliasId) {
|
||||
// Alias response (Guardian Only mode) — verify alias belongs to current user
|
||||
const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
|
||||
// Alias response (Guardian Only mode) — verify alias belongs to current user or their partner
|
||||
const alias = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_alias_availability (event_id,alias_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (event_id,alias_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||
`, [event.id, aliasId, response, trimmedNote]);
|
||||
} else {
|
||||
// Regular user response
|
||||
// Regular user response — also allowed if partner is in the event's group
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const avPartner = await getPartnerId(req.schema, req.user.id);
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, req.user.id]);
|
||||
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
|
||||
`, [event.id, req.user.id, avPartner || -1]);
|
||||
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||
@@ -676,7 +743,14 @@ router.delete('/:id/availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { aliasId } = req.query;
|
||||
if (aliasId) {
|
||||
const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
|
||||
const alias = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
|
||||
await exec(req.schema, 'DELETE FROM event_alias_availability WHERE event_id=$1 AND alias_id=$2', [req.params.id, aliasId]);
|
||||
} else {
|
||||
@@ -692,14 +766,15 @@ router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
let saved = 0;
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const bulkPartnerId = await getPartnerId(req.schema, req.user.id);
|
||||
for (const { eventId, response } of responses) {
|
||||
if (!['going','maybe','not_going'].includes(response)) continue;
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
if (!event || !event.track_availability) continue;
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [eventId, req.user.id]);
|
||||
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
|
||||
`, [eventId, req.user.id, bulkPartnerId || -1]);
|
||||
if (!inGroup && !itm) continue;
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||
|
||||
@@ -451,11 +451,58 @@ router.get('/aliases-all', authMiddleware, teamManagerMiddleware, async (req, re
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// List current user's aliases
|
||||
// Get current user's partner (spouse/partner relationship)
|
||||
router.get('/me/partner', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const partner = await queryOne(req.schema,
|
||||
`SELECT u.id, u.name, u.display_name, u.avatar
|
||||
FROM guardian_partners gp
|
||||
JOIN users u ON u.id = CASE WHEN gp.user_id_1=$1 THEN gp.user_id_2 ELSE gp.user_id_1 END
|
||||
WHERE gp.user_id_1=$1 OR gp.user_id_2=$1`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ partner: partner || null });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Set partner (replaces any existing partnership for this user)
|
||||
router.post('/me/partner', authMiddleware, async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const partnerId = parseInt(req.body.partnerId);
|
||||
if (!partnerId || partnerId === userId) return res.status(400).json({ error: 'Invalid partner' });
|
||||
const uid1 = Math.min(userId, partnerId);
|
||||
const uid2 = Math.max(userId, partnerId);
|
||||
try {
|
||||
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [userId]);
|
||||
await exec(req.schema, 'INSERT INTO guardian_partners (user_id_1,user_id_2) VALUES ($1,$2)', [uid1, uid2]);
|
||||
const partner = await queryOne(req.schema,
|
||||
'SELECT id,name,display_name,avatar FROM users WHERE id=$1',
|
||||
[partnerId]
|
||||
);
|
||||
res.json({ partner });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Remove partner
|
||||
router.delete('/me/partner', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// List current user's aliases (includes partner's aliases)
|
||||
router.get('/me/aliases', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const aliases = await query(req.schema,
|
||||
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name',
|
||||
`SELECT id,first_name,last_name,email,date_of_birth,avatar,phone
|
||||
FROM guardian_aliases
|
||||
WHERE guardian_id=$1
|
||||
OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||
)
|
||||
ORDER BY first_name,last_name`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ aliases });
|
||||
@@ -496,7 +543,14 @@ router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
|
||||
const existing = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
await exec(req.schema,
|
||||
'UPDATE guardian_aliases SET first_name=$1,last_name=$2,email=$3,date_of_birth=$4,phone=$5,updated_at=NOW() WHERE id=$6',
|
||||
@@ -514,7 +568,14 @@ router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||
router.delete('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||
const aliasId = parseInt(req.params.aliasId);
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
|
||||
const existing = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE id=$1', [aliasId]);
|
||||
res.json({ success: true });
|
||||
@@ -526,7 +587,14 @@ router.post('/me/aliases/:aliasId/avatar', authMiddleware, uploadAliasAvatar.sin
|
||||
const aliasId = parseInt(req.params.aliasId);
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
|
||||
const existing = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
const sharp = require('sharp');
|
||||
const filePath = req.file.path;
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function AddChildAliasModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
const [aliases, setAliases] = useState([]);
|
||||
const [editingAlias, setEditingAlias] = useState(null); // null = new entry
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||
const [avatarFile, setAvatarFile] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Partner state
|
||||
const [partner, setPartner] = useState(null);
|
||||
const [selectedPartnerId, setSelectedPartnerId] = useState('');
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [savingPartner, setSavingPartner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getAliases().then(({ aliases }) => setAliases(aliases || [])).catch(() => {});
|
||||
Promise.all([
|
||||
api.getAliases(),
|
||||
api.getPartner(),
|
||||
api.searchUsers(''),
|
||||
]).then(([aliasRes, partnerRes, usersRes]) => {
|
||||
setAliases(aliasRes.aliases || []);
|
||||
setPartner(partnerRes.partner || null);
|
||||
setSelectedPartnerId(partnerRes.partner?.id?.toString() || '');
|
||||
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
|
||||
@@ -35,6 +52,27 @@ export default function AddChildAliasModal({ onClose }) {
|
||||
setAvatarFile(null);
|
||||
};
|
||||
|
||||
const handleSavePartner = async () => {
|
||||
setSavingPartner(true);
|
||||
try {
|
||||
if (!selectedPartnerId) {
|
||||
await api.removePartner();
|
||||
setPartner(null);
|
||||
toast('Spouse/Partner removed', 'success');
|
||||
} else {
|
||||
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId));
|
||||
setPartner(p);
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
toast('Spouse/Partner saved', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSavingPartner(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.firstName.trim() || !form.lastName.trim())
|
||||
return toast('First and last name required', 'error');
|
||||
@@ -93,7 +131,7 @@ export default function AddChildAliasModal({ onClose }) {
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Add Child Alias</h2>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Family Manager</h2>
|
||||
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
@@ -101,6 +139,37 @@ export default function AddChildAliasModal({ onClose }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Spouse/Partner section */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{lbl('Spouse/Partner')}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
value={selectedPartnerId}
|
||||
onChange={e => setSelectedPartnerId(e.target.value)}
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{allUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.display_name || u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSavePartner}
|
||||
disabled={savingPartner}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{savingPartner ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{partner && (
|
||||
<div className="text-sm" style={{ color: 'var(--text-secondary)', marginTop: 4 }}>
|
||||
Linked with {partner.display_name || partner.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing aliases list */}
|
||||
{aliases.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
|
||||
{canAccessTools && features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
|
||||
{showAddChild && onAddChild && item(
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>,
|
||||
'Add Child Aliase',
|
||||
'Family Manager',
|
||||
onAddChild
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -83,6 +83,10 @@ export const api = {
|
||||
const form = new FormData(); form.append('avatar', file);
|
||||
return req('POST', `/users/me/aliases/${aliasId}/avatar`, form);
|
||||
},
|
||||
// Spouse/Partner
|
||||
getPartner: () => req('GET', '/users/me/partner'),
|
||||
setPartner: (partnerId) => req('POST', '/users/me/partner', { partnerId }),
|
||||
removePartner: () => req('DELETE', '/users/me/partner'),
|
||||
|
||||
// Groups
|
||||
getGroups: () => req('GET', '/groups'),
|
||||
|
||||
Reference in New Issue
Block a user