v0.12.9 bug fixes (FCM and list ordering)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.8",
|
"version": "0.12.9",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.8}"
|
VERSION="${1:-0.12.9}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.8",
|
"version": "0.12.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
|||||||
Members ({members.length})
|
Members ({members.length})
|
||||||
</div>
|
</div>
|
||||||
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{members.map(m => (
|
{[...members].sort((a, b) => a.name.localeCompare(b.name)).map(m => (
|
||||||
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
|
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
|
||||||
<Avatar user={m} size="sm" />
|
<Avatar user={m} size="sm" />
|
||||||
<span className="flex-1 text-sm">{m.name}</span>
|
<span className="flex-1 text-sm">{m.name}</span>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||||
{users.filter(u => u.id !== user.id && u.allow_dm !== 0).map(u => (
|
{users.filter(u => u.id !== user.id && u.allow_dm !== 0).sort((a, b) => a.name.localeCompare(b.name)).map(u => (
|
||||||
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
||||||
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
|
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
|
||||||
<Avatar user={u} size="sm" />
|
<Avatar user={u} size="sm" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function TeamManagementTab() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getUserGroups().then(({ groups }) => setUserGroups(groups || [])).catch(() => {});
|
api.getUserGroups().then(({ groups }) => setUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {});
|
||||||
api.getSettings().then(({ settings }) => {
|
api.getSettings().then(({ settings }) => {
|
||||||
// Read from unified key, fall back to legacy key
|
// Read from unified key, fall back to legacy key
|
||||||
setToolManagers(JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'));
|
setToolManagers(JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'));
|
||||||
@@ -313,80 +313,6 @@ function PushDebugTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Web Push Tab ──────────────────────────────────────────────────────────────
|
|
||||||
function WebPushTab() {
|
|
||||||
const toast = useToast();
|
|
||||||
const [vapidPublic, setVapidPublic] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const [showRegenWarning, setShowRegenWarning] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.getSettings().then(({ settings }) => {
|
|
||||||
setVapidPublic(settings.vapid_public || '');
|
|
||||||
setLoading(false);
|
|
||||||
}).catch(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const doGenerate = async () => {
|
|
||||||
setGenerating(true);
|
|
||||||
setShowRegenWarning(false);
|
|
||||||
try {
|
|
||||||
const { publicKey } = await api.generateVapidKeys();
|
|
||||||
setVapidPublic(publicKey);
|
|
||||||
toast('VAPID keys generated. Push notifications are now active.', 'success');
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message || 'Failed to generate keys', 'error');
|
|
||||||
} finally { setGenerating(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Loading…</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="settings-section-label" style={{ marginBottom: 12 }}>Web Push Notifications (VAPID)</div>
|
|
||||||
|
|
||||||
{vapidPublic ? (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 12px', marginBottom: 10 }}>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}>Public Key</div>
|
|
||||||
<code style={{ fontSize: 11, color: 'var(--text-primary)', wordBreak: 'break-all', lineHeight: 1.5, display: 'block' }}>{vapidPublic}</code>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--success)', display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
|
||||||
Push notifications active
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
|
||||||
No VAPID keys found. Generate keys to enable Web Push notifications.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showRegenWarning && (
|
|
||||||
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '14px 16px', marginBottom: 16 }}>
|
|
||||||
<p style={{ fontSize: 13, fontWeight: 600, color: 'var(--error)', marginBottom: 8 }}>⚠️ Regenerate VAPID keys?</p>
|
|
||||||
<p style={{ fontSize: 13, color: '#5c2c28', marginBottom: 12, lineHeight: 1.5 }}>
|
|
||||||
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Users will need to re-enable notifications.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={doGenerate} disabled={generating}>{generating ? 'Generating…' : 'Yes, regenerate keys'}</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!showRegenWarning && (
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => vapidPublic ? setShowRegenWarning(true) : doGenerate()} disabled={generating}>
|
|
||||||
{generating ? 'Generating…' : vapidPublic ? 'Regenerate Keys' : 'Generate Keys'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 12, lineHeight: 1.5 }}>
|
|
||||||
Requires HTTPS. On iOS, the app must be installed to the home screen first.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main modal ────────────────────────────────────────────────────────────────
|
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||||
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
||||||
const [tab, setTab] = useState('registration');
|
const [tab, setTab] = useState('registration');
|
||||||
@@ -406,7 +332,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
isTeam && { id: 'team', label: 'Team Management' },
|
isTeam && { id: 'team', label: 'Team Management' },
|
||||||
{ id: 'registration', label: 'Registration' },
|
{ id: 'registration', label: 'Registration' },
|
||||||
{ id: 'webpush', label: 'Web Push' },
|
|
||||||
{ id: 'pushdebug', label: 'Push Debug' },
|
{ id: 'pushdebug', label: 'Push Debug' },
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
@@ -431,7 +356,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
|||||||
|
|
||||||
{tab === 'team' && <TeamManagementTab />}
|
{tab === 'team' && <TeamManagementTab />}
|
||||||
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
|
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
|
||||||
{tab === 'webpush' && <WebPushTab />}
|
|
||||||
{tab === 'pushdebug' && <PushDebugTab />}
|
{tab === 'pushdebug' && <PushDebugTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,10 +66,21 @@ if ('serviceWorker' in navigator) {
|
|||||||
currentScale = Math.round(newScale * 100) / 100;
|
currentScale = Math.round(newScale * 100) / 100;
|
||||||
document.documentElement.style.setProperty('--font-scale', currentScale);
|
document.documentElement.style.setProperty('--font-scale', currentScale);
|
||||||
} else if (e.touches.length === 1 && isStandalone) {
|
} else if (e.touches.length === 1 && isStandalone) {
|
||||||
// Single finger: block pull-to-refresh at top of page
|
// Single finger: block pull-to-refresh only when no scrollable ancestor
|
||||||
|
// has scrolled content above the viewport.
|
||||||
|
// Without this ancestor check, document.scrollTop is always 0 in this
|
||||||
|
// flex layout, so the naive condition blocked ALL upward swipes (dy > 0),
|
||||||
|
// making any scroll container impossible to scroll back up after reaching
|
||||||
|
// the bottom — freezing the window.
|
||||||
const dy = e.touches[0].clientY - singleStartY;
|
const dy = e.touches[0].clientY - singleStartY;
|
||||||
if (dy > 0 && document.documentElement.scrollTop === 0 && document.body.scrollTop === 0) {
|
if (dy > 0) {
|
||||||
e.preventDefault();
|
let el = e.target;
|
||||||
|
let canScrollUp = false;
|
||||||
|
while (el && el !== document.documentElement) {
|
||||||
|
if (el.scrollTop > 0) { canScrollUp = true; break; }
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
if (!canScrollUp) e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(() =>
|
const load = useCallback(() =>
|
||||||
api.getUserGroups().then(({ groups }) => setGroups(groups)).catch(() => {}), []);
|
api.getUserGroups().then(({ groups }) => setGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}), []);
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const selectGroup = async (g) => {
|
const selectGroup = async (g) => {
|
||||||
@@ -220,7 +220,7 @@ function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey, isMobile = fa
|
|||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(() =>
|
const load = useCallback(() =>
|
||||||
api.getMultiGroupDms().then(({ dms }) => setDms(dms||[])).catch(() => {}), []);
|
api.getMultiGroupDms().then(({ dms }) => setDms([...(dms||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}), []);
|
||||||
useEffect(() => { load(); }, [load, refreshKey]);
|
useEffect(() => { load(); }, [load, refreshKey]);
|
||||||
|
|
||||||
const clearSelection = () => { setSelected(null); setDmName(''); setGroupIds(new Set()); setSavedGroupIds(new Set()); setShowDelete(false); };
|
const clearSelection = () => { setSelected(null); setDmName(''); setGroupIds(new Set()); setSavedGroupIds(new Set()); setShowDelete(false); };
|
||||||
@@ -556,8 +556,8 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
|
|||||||
const onRefresh = () => setRefreshKey(k => k+1);
|
const onRefresh = () => setRefreshKey(k => k+1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active'))).catch(() => {});
|
api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active').sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {});
|
||||||
api.getUserGroups().then(({ groups }) => setAllUserGroups(groups)).catch(() => {});
|
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {});
|
||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
|
|
||||||
// Nav item helper — matches Schedule page style
|
// Nav item helper — matches Schedule page style
|
||||||
|
|||||||
@@ -300,11 +300,13 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {});
|
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {});
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const filtered = users.filter(u =>
|
const filtered = users
|
||||||
!search || u.name?.toLowerCase().includes(search.toLowerCase()) ||
|
.filter(u =>
|
||||||
u.display_name?.toLowerCase().includes(search.toLowerCase()) ||
|
!search || u.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
u.email?.toLowerCase().includes(search.toLowerCase())
|
u.display_name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
);
|
u.email?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// ── Nav item helper (matches Schedule page style) ─────────────────────────
|
// ── Nav item helper (matches Schedule page style) ─────────────────────────
|
||||||
const navItem = (label, key) => (
|
const navItem = (label, key) => (
|
||||||
@@ -354,7 +356,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ flex:1, overflowY:'auto', padding:16 }}>
|
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 72 : 16, overscrollBehavior: 'contain' }}>
|
||||||
{tab === 'users' && (
|
{tab === 'users' && (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
|
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
|||||||
Reference in New Issue
Block a user