diff --git a/.github/workflows/close-untitled-issues.yml b/.github/workflows/close-untitled-issues.yml new file mode 100644 index 00000000..0d7b7400 --- /dev/null +++ b/.github/workflows/close-untitled-issues.yml @@ -0,0 +1,67 @@ +name: Close untitled issues + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + check-title: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Close if title is empty or generic + uses: actions/github-script@v7 + with: + script: | + const title = context.payload.issue.title.trim(); + const badTitles = [ + "[BUG]", + "bug report", + "bug", + "issue", + ]; + + const featureRequestTitles = [ + "feature request", + "[feature]", + "[feature request]", + "[enhancement]" + ] + + const titleLower = title.toLowerCase(); + + if (badTitles.includes(titleLower)) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem." + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + state: "closed", + state_reason: "not_planned" + }); + } else if (featureRequestTitles.some(t => titleLower.startsWith(t))) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed." + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + state: "closed", + state_reason: "not_planned" + }); + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..fb161122 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Tests + +permissions: + contents: read + +on: + push: + branches: [main, dev] + paths: + - 'server/**' + - '.github/workflows/test.yml' + pull_request: + branches: [main, dev] + paths: + - 'server/**' + - '.github/workflows/test.yml' + +jobs: + server-tests: + name: Server Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: server/package-lock.json + + - name: Install dependencies + run: cd server && npm ci + + - name: Run tests + run: cd server && npm run test:coverage + + - name: Upload coverage + if: success() + uses: actions/upload-artifact@v6 + with: + name: coverage + path: server/coverage/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 24ca73e7..fbf5e80a 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ coverage .cache *.tsbuildinfo *.tgz + +.scannerwork \ No newline at end of file diff --git a/README.md b/README.md index 0e122ada..6f601af4 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ services: # - DEMO_MODE=false # Enable demo mode (resets data hourly) # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist # - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist + # - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) volumes: - ./data:/app/data - ./uploads:/app/uploads @@ -301,6 +302,7 @@ trek.yourdomain.com { | `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random | | **Other** | | | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | +| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` | ## Optional API Keys diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 73225055..af3a7182 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -7,18 +7,57 @@ metadata: data: NODE_ENV: {{ .Values.env.NODE_ENV | quote }} PORT: {{ .Values.env.PORT | quote }} + {{- if .Values.env.TZ }} + TZ: {{ .Values.env.TZ | quote }} + {{- end }} + {{- if .Values.env.LOG_LEVEL }} + LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }} + {{- end }} {{- if .Values.env.ALLOWED_ORIGINS }} ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }} {{- end }} {{- if .Values.env.APP_URL }} APP_URL: {{ .Values.env.APP_URL | quote }} {{- end }} - {{- if .Values.env.ALLOW_INTERNAL_NETWORK }} - ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }} + {{- if .Values.env.FORCE_HTTPS }} + FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }} {{- end }} {{- if .Values.env.COOKIE_SECURE }} COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }} {{- end }} + {{- if .Values.env.TRUST_PROXY }} + TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }} + {{- end }} + {{- if .Values.env.ALLOW_INTERNAL_NETWORK }} + ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }} + {{- end }} + {{- if .Values.env.OIDC_ISSUER }} + OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }} + {{- end }} + {{- if .Values.env.OIDC_CLIENT_ID }} + OIDC_CLIENT_ID: {{ .Values.env.OIDC_CLIENT_ID | quote }} + {{- end }} + {{- if .Values.env.OIDC_DISPLAY_NAME }} + OIDC_DISPLAY_NAME: {{ .Values.env.OIDC_DISPLAY_NAME | quote }} + {{- end }} + {{- if .Values.env.OIDC_ONLY }} + OIDC_ONLY: {{ .Values.env.OIDC_ONLY | quote }} + {{- end }} + {{- if .Values.env.OIDC_ADMIN_CLAIM }} + OIDC_ADMIN_CLAIM: {{ .Values.env.OIDC_ADMIN_CLAIM | quote }} + {{- end }} + {{- if .Values.env.OIDC_ADMIN_VALUE }} + OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }} + {{- end }} + {{- if .Values.env.OIDC_SCOPE }} + OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }} + {{- end }} {{- if .Values.env.OIDC_DISCOVERY_URL }} OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }} {{- end }} + {{- if .Values.env.DEMO_MODE }} + DEMO_MODE: {{ .Values.env.DEMO_MODE | quote }} + {{- end }} + {{- if .Values.env.MCP_RATE_LIMIT }} + MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }} + {{- end }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 2f0cdb80..6a5e355c 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -54,6 +54,12 @@ spec: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: ADMIN_PASSWORD optional: true + - name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: OIDC_CLIENT_SECRET + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml index a205f8fd..20edd114 100644 --- a/chart/templates/secret.yaml +++ b/chart/templates/secret.yaml @@ -14,6 +14,9 @@ data: {{- if .Values.secretEnv.ADMIN_PASSWORD }} ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | b64enc | quote }} {{- end }} + {{- if .Values.secretEnv.OIDC_CLIENT_SECRET }} + OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }} + {{- end }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} @@ -38,4 +41,7 @@ stringData: {{- if .Values.secretEnv.ADMIN_PASSWORD }} ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }} {{- end }} + {{- if .Values.secretEnv.OIDC_CLIENT_SECRET }} + OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }} + {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 9501c605..68cf2746 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -15,20 +15,44 @@ service: env: NODE_ENV: production PORT: 3000 + # TZ: "UTC" + # Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin). + # LOG_LEVEL: "info" + # "info" = concise user actions, "debug" = verbose details. # ALLOWED_ORIGINS: "" # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. # APP_URL: "https://trek.example.com" # Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP. # Also used as the base URL for links in email notifications and other external links. + # FORCE_HTTPS: "false" + # Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy. + # COOKIE_SECURE: "true" + # Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production. + # TRUST_PROXY: "1" + # Number of trusted reverse proxies for X-Forwarded-For header parsing. # ALLOW_INTERNAL_NETWORK: "false" # Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address. # Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked. - # COOKIE_SECURE: "true" - # Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production. - # OIDC_DISCOVERY_URL: "" - # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik). + # OIDC_ISSUER: "" + # OpenID Connect provider URL. + # OIDC_CLIENT_ID: "" + # OIDC client ID. + # OIDC_DISPLAY_NAME: "SSO" + # Label shown on the SSO login button. + # OIDC_ONLY: "false" + # Set to "true" to disable local password auth entirely (first SSO login becomes admin). + # OIDC_ADMIN_CLAIM: "" + # OIDC claim used to identify admin users. + # OIDC_ADMIN_VALUE: "" + # Value of the OIDC claim that grants admin role. # OIDC_SCOPE: "openid email profile groups" # Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM. + # OIDC_DISCOVERY_URL: "" + # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik). + # DEMO_MODE: "false" + # Enable demo mode (hourly data resets). + # MCP_RATE_LIMIT: "60" + # Max MCP API requests per user per minute. Defaults to 60. # Secret environment variables stored in a Kubernetes Secret. @@ -46,6 +70,8 @@ secretEnv: # If either is empty a random password is generated and printed to the server log. ADMIN_EMAIL: "" ADMIN_PASSWORD: "" + # OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID. + OIDC_CLIENT_SECRET: "" # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades generateEncryptionKey: false diff --git a/client/src/api/client.ts b/client/src/api/client.ts index cb521726..81a28b60 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -83,6 +83,7 @@ export const tripsApi = { getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), + copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), } export const daysApi = { diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index 2dc6dfa6..96a3b286 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -119,7 +119,7 @@ export default function GitHubPanel() { return (
{/* Support cards */} -
+
+ { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} + > +
+ +
+
+
Discord
+
Join the community
+
+ +
{/* Loading / Error / Releases */} diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 1486b558..933ca844 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -5,6 +5,7 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react' import { collabApi } from '../../api/client' +import { getAuthUrl } from '../../api/authUrl' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' @@ -96,22 +97,33 @@ interface FilePreviewPortalProps { } function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { + const [authUrl, setAuthUrl] = useState('') + const rawUrl = file?.url || '' + useEffect(() => { + if (!rawUrl) return + getAuthUrl(rawUrl, 'download').then(setAuthUrl) + }, [rawUrl]) + if (!file) return null - const url = file.url || `/uploads/${file.filename}` const isImage = file.mime_type?.startsWith('image/') const isPdf = file.mime_type === 'application/pdf' const isTxt = file.mime_type?.startsWith('text/') + const openInNewTab = async () => { + const u = await getAuthUrl(rawUrl, 'download') + window.open(u, '_blank', 'noreferrer') + } + return ReactDOM.createPortal(
{isImage ? ( /* Image lightbox — floating controls */
e.stopPropagation()}> - {file.original_name} + {file.original_name}
{file.original_name}
- +
@@ -122,19 +134,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
{file.original_name}
- +
{(isPdf || isTxt) ? ( - +

- Download +

) : (
- Download {file.original_name} +
)} @@ -144,6 +156,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { ) } +function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { + const [authSrc, setAuthSrc] = useState('') + useEffect(() => { + getAuthUrl(src, 'download').then(setAuthSrc) + }, [src]) + return authSrc ? {alt} : null +} + const NOTE_COLORS = [ { value: '#6366f1', label: 'Indigo' }, { value: '#ef4444', label: 'Red' }, @@ -460,7 +480,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
{t('collab.notes.attachFiles')}
- { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} /> + { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
{/* Existing attachments (edit mode) */} {existingAttachments.map(a => { @@ -484,10 +504,10 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
))} - + } @@ -845,7 +865,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi const isImage = a.mime_type?.startsWith('image/') const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?' return isImage ? ( - {a.original_name} onPreviewFile?.(a)} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} @@ -974,7 +994,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) - try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {} + try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) } } // Reload note with attachments const fresh = await collabApi.getNotes(tripId) diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 15326932..1d928762 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -232,9 +232,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: {appVersion && (
- )} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index a5328b2c..b9850f37 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -879,7 +879,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const placeItems = merged.filter(i => i.type === 'place') return ( -
+
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
{ onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }} @@ -896,6 +896,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none', outlineOffset: -2, borderRadius: isDragTarget ? 8 : 0, + touchAction: 'manipulation', }} onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }} @@ -1553,8 +1554,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ value={ui.text} onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }} - placeholder={t('dayplan.noteTitle')} - style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }} + placeholder={t('dayplan.noteTitle') + ' *'} + required + style={{ fontSize: 13, fontWeight: 500, border: `1px solid ${!ui.text?.trim() ? 'var(--border-primary)' : 'var(--border-primary)'}`, borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }} />