mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
e224befde7
* feat(planner): reorder days in a modal instead of a dropdown The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged. * feat(map): explore reliability, Mapbox popups + compass, region-biased search POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out. Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north. /api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result. * feat(dashboard): list-view and mobile polish Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts. Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar. * feat: small community-requested options Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields. * test(shared): bump day-note subtitle limit assertion to 250 * test: align specs with the new search param order and archive label Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
336 lines
15 KiB
TypeScript
336 lines
15 KiB
TypeScript
import React from 'react'
|
|
import { adminApi } from '../../api/client'
|
|
import Modal from '../../components/shared/Modal'
|
|
import CustomSelect from '../../components/shared/CustomSelect'
|
|
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
|
|
import type { TranslationFn } from '../../types'
|
|
import type { useAdmin } from './useAdmin'
|
|
|
|
interface AdminUserModalsProps {
|
|
admin: ReturnType<typeof useAdmin>
|
|
t: TranslationFn
|
|
}
|
|
|
|
// The admin page's modal layer: create-user, edit-user, the "how to update"
|
|
// popup and the rotate-JWT confirmation. Pure layout around the useAdmin hook.
|
|
export default function AdminUserModals({ admin, t }: AdminUserModalsProps): React.ReactElement {
|
|
const {
|
|
logout, navigate, toast,
|
|
editingUser, setEditingUser, editForm, setEditForm,
|
|
showCreateUser, setShowCreateUser, createForm, setCreateForm,
|
|
updateInfo, showUpdateModal, setShowUpdateModal,
|
|
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
|
handleCreateUser, handleSaveUser,
|
|
} = admin
|
|
const [showCreatePw, setShowCreatePw] = React.useState(false)
|
|
const [showEditPw, setShowEditPw] = React.useState(false)
|
|
|
|
return (
|
|
<>
|
|
{/* Create user modal */}
|
|
<Modal
|
|
isOpen={showCreateUser}
|
|
onClose={() => setShowCreateUser(false)}
|
|
title={t('admin.createUser')}
|
|
size="sm"
|
|
footer={
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={() => setShowCreateUser(false)}
|
|
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleCreateUser}
|
|
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
|
|
>
|
|
{t('admin.createUser')}
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')} *</label>
|
|
<input
|
|
type="text"
|
|
value={createForm.username}
|
|
onChange={e => setCreateForm(f => ({ ...f, username: e.target.value }))}
|
|
placeholder={t('settings.username')}
|
|
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')} *</label>
|
|
<input
|
|
type="email"
|
|
value={createForm.email}
|
|
onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))}
|
|
placeholder={t('common.email')}
|
|
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showCreatePw ? 'text' : 'password'}
|
|
value={createForm.password}
|
|
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
|
placeholder={t('common.password')}
|
|
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowCreatePw(v => !v)}
|
|
tabIndex={-1}
|
|
aria-label="Show or hide password"
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
|
>
|
|
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
|
<CustomSelect
|
|
value={createForm.role}
|
|
onChange={value => setCreateForm(f => ({ ...f, role: String(value) }))}
|
|
options={[
|
|
{ value: 'user', label: t('settings.roleUser') },
|
|
{ value: 'admin', label: t('settings.roleAdmin') },
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Edit user modal */}
|
|
<Modal
|
|
isOpen={!!editingUser}
|
|
onClose={() => setEditingUser(null)}
|
|
title={t('admin.editUser')}
|
|
size="sm"
|
|
footer={
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={() => setEditingUser(null)}
|
|
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleSaveUser}
|
|
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
|
|
>
|
|
{t('common.save')}
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
{editingUser && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.username}
|
|
onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))}
|
|
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</label>
|
|
<input
|
|
type="email"
|
|
value={editForm.email}
|
|
onChange={e => setEditForm(f => ({ ...f, email: e.target.value }))}
|
|
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
|
<div className="relative">
|
|
<input
|
|
type={showEditPw ? 'text' : 'password'}
|
|
value={editForm.password}
|
|
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
|
placeholder={t('admin.newPasswordPlaceholder')}
|
|
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowEditPw(v => !v)}
|
|
tabIndex={-1}
|
|
aria-label="Show or hide password"
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
|
>
|
|
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
|
<CustomSelect
|
|
value={editForm.role}
|
|
onChange={value => setEditForm(f => ({ ...f, role: String(value) }))}
|
|
options={[
|
|
{ value: 'user', label: t('settings.roleUser') },
|
|
{ value: 'admin', label: t('settings.roleAdmin') },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="pt-3 border-t border-slate-100">
|
|
<p className="text-xs text-slate-400 mb-2">{t('admin.passkey.resetHint')}</p>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
if (!editingUser) return
|
|
if (!confirm(t('admin.passkey.resetConfirm', { name: editingUser.username }))) return
|
|
try {
|
|
const r = await adminApi.resetUserPasskeys(editingUser.id)
|
|
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }))
|
|
} catch {
|
|
toast.error(t('common.error'))
|
|
}
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
|
>
|
|
<Fingerprint size={14} /> {t('admin.passkey.reset')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Update instructions popup */}
|
|
{showUpdateModal && (
|
|
<div
|
|
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
|
onClick={() => setShowUpdateModal(false)}
|
|
>
|
|
<div
|
|
onClick={e => e.stopPropagation()}
|
|
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
|
>
|
|
<div style={{ background: 'linear-gradient(135deg, #0f172a, #1e293b)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
<ArrowUpCircle size={20} className="text-white" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{t('admin.update.howTo')}</h3>
|
|
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
|
|
v{updateInfo?.current} → v{updateInfo?.latest}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ padding: '20px 24px' }}>
|
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
|
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
|
</p>
|
|
|
|
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
|
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
|
>
|
|
{`docker pull mauriceboe/trek:latest
|
|
docker stop trek && docker rm trek
|
|
docker run -d --name trek \\
|
|
-p 3000:3000 \\
|
|
-v /opt/trek/data:/app/data \\
|
|
-v /opt/trek/uploads:/app/uploads \\
|
|
--restart unless-stopped \\
|
|
mauriceboe/trek:latest`}
|
|
</div>
|
|
|
|
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
|
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
|
<span>{t('admin.update.dataInfo')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{updateInfo?.release_url && (
|
|
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
|
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<ExternalLink className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
|
<span>
|
|
<a href={updateInfo.release_url} target="_blank" rel="noopener noreferrer" className="underline font-semibold">
|
|
{t('admin.update.button')}
|
|
</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end' }}>
|
|
<button
|
|
onClick={() => setShowUpdateModal(false)}
|
|
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
|
>
|
|
{t('common.close')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rotate JWT Secret confirmation modal */}
|
|
<Modal
|
|
isOpen={showRotateJwtModal}
|
|
onClose={() => setShowRotateJwtModal(false)}
|
|
title="Rotate JWT Secret"
|
|
size="sm"
|
|
footer={
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={() => setShowRotateJwtModal(false)}
|
|
disabled={rotatingJwt}
|
|
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={async () => {
|
|
setRotatingJwt(true)
|
|
try {
|
|
await adminApi.rotateJwtSecret()
|
|
setShowRotateJwtModal(false)
|
|
logout()
|
|
navigate('/login', { state: { noRedirect: true } })
|
|
} catch {
|
|
toast.error(t('common.error'))
|
|
setRotatingJwt(false)
|
|
}
|
|
}}
|
|
disabled={rotatingJwt}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white rounded-lg font-medium"
|
|
>
|
|
{rotatingJwt ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
|
Rotate & Log out
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="flex gap-3">
|
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-900 mb-1">Warning, this will invalidate all sessions and log you out.</p>
|
|
<p className="text-xs text-slate-500">A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.</p>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|