mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f6be35870 | |||
| f47852d689 | |||
| be248e1ad4 | |||
| e290c7c522 | |||
| f20eb6639f | |||
| d0176d7ed6 | |||
| 8402f3bcfd | |||
| 6caa966a52 | |||
| 098918b416 | |||
| 28c7013252 | |||
| fa810c3bab | |||
| 93d5ab7fcd | |||
| 729526bd34 | |||
| c13b28ae8f | |||
| 8c7d1f8fa6 | |||
| dba655d6e8 | |||
| cb8280249f | |||
| 504195a324 | |||
| 47b880221d | |||
| a6ea73eab6 | |||
| 4ba6005ca3 | |||
| 09ab829b17 | |||
| 66a057a070 | |||
| f2ffea5ba4 | |||
| b0dee4dafb | |||
| beb48af8ed | |||
| e2be3ec191 | |||
| 68a1f9683e | |||
| 5c57116a68 | |||
| 48508b9df4 | |||
| c8250256a7 | |||
| 6491e1f986 | |||
| 03757ed0af | |||
| a676dbe881 | |||
| 411d8620ba | |||
| f45f56318a | |||
| 3ae0f3f819 | |||
| 306626ee1c | |||
| 7e0fe3b1b9 | |||
| fdbc015dbf | |||
| 7d8e3912b4 | |||
| 9ebca725ae | |||
| 9718187490 | |||
| aa0620e01f |
@@ -0,0 +1,21 @@
|
|||||||
|
## Description
|
||||||
|
<!-- What does this PR do? Why? -->
|
||||||
|
|
||||||
|
## Related Issue or Discussion
|
||||||
|
<!-- This project requires an issue or an approved feature request before submitting a PR. -->
|
||||||
|
<!-- For bug fixes: Closes #ISSUE_NUMBER -->
|
||||||
|
<!-- For features: Addresses discussion #DISCUSSION_NUMBER -->
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Breaking change
|
||||||
|
- [ ] Documentation update
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||||
|
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||||
|
- [ ] This PR targets the `dev` branch, not `main`
|
||||||
|
- [ ] I have tested my changes locally
|
||||||
|
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||||
|
- [ ] I have updated documentation if needed
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Close issues with unchanged bad titles
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */6 * * *' # Every 6 hours
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close stale invalid-title issues
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const badTitles = [
|
||||||
|
"[bug]", "bug report", "bug", "issue",
|
||||||
|
"help", "question", "test", "...", "untitled"
|
||||||
|
];
|
||||||
|
|
||||||
|
const { data: issues } = await github.rest.issues.listForRepo({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
labels: 'invalid-title',
|
||||||
|
state: 'open',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
const createdAt = new Date(issue.created_at);
|
||||||
|
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||||
|
|
||||||
|
const titleLower = issue.title.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!badTitles.includes(titleLower)) {
|
||||||
|
// Title was fixed — remove the label and move on
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
name: 'invalid-title',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still a bad title after 24h — close it
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
body: [
|
||||||
|
'## Issue closed',
|
||||||
|
'',
|
||||||
|
'This issue has been automatically closed because the title was not updated within 24 hours.',
|
||||||
|
'',
|
||||||
|
'Feel free to open a new issue with a descriptive title that summarizes the problem.',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Close PRs with unchanged wrong base branch
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */6 * * *' # Every 6 hours
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close stale wrong-base-branch PRs
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { data: pulls } = await github.rest.pulls.list({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
for (const pull of pulls) {
|
||||||
|
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||||
|
if (!hasLabel) continue;
|
||||||
|
|
||||||
|
const createdAt = new Date(pull.created_at);
|
||||||
|
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||||
|
|
||||||
|
// Base was fixed — remove label and move on
|
||||||
|
if (pull.base.ref !== 'main') {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pull.number,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still targeting main after 24h — close it
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pull.number,
|
||||||
|
body: [
|
||||||
|
'## PR closed',
|
||||||
|
'',
|
||||||
|
'This PR has been automatically closed because the base branch was not updated to `dev` within 24 hours.',
|
||||||
|
'',
|
||||||
|
'Feel free to open a new PR targeting `dev`.',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: pull.number,
|
||||||
|
state: 'closed',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Close untitled issues
|
name: Flag issues with bad titles
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
@@ -10,58 +10,83 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
check-title:
|
check-title:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Close if title is empty or generic
|
- name: Flag or redirect issue
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const title = context.payload.issue.title.trim();
|
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();
|
const titleLower = title.toLowerCase();
|
||||||
|
|
||||||
|
const badTitles = [
|
||||||
|
"[bug]", "bug report", "bug", "issue",
|
||||||
|
"help", "question", "test", "...", "untitled"
|
||||||
|
];
|
||||||
|
|
||||||
|
const featureRequestTitles = [
|
||||||
|
"feature request", "[feature]", "[feature request]", "[enhancement]"
|
||||||
|
];
|
||||||
|
|
||||||
if (badTitles.includes(titleLower)) {
|
if (badTitles.includes(titleLower)) {
|
||||||
|
// Ensure the label exists
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'invalid-title',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'invalid-title',
|
||||||
|
color: 'e4e669',
|
||||||
|
description: 'Issue title does not meet quality standards',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
labels: ['invalid-title'],
|
||||||
|
});
|
||||||
|
|
||||||
await github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: context.payload.issue.number,
|
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."
|
body: [
|
||||||
|
'## Invalid title',
|
||||||
|
'',
|
||||||
|
`Your issue title \`${title}\` is too generic to be actionable.`,
|
||||||
|
'',
|
||||||
|
'Please edit the title to something descriptive that summarizes the problem — for example:',
|
||||||
|
'> _Map view crashes when zooming in on Safari 17_',
|
||||||
|
'',
|
||||||
|
'**This issue will be automatically closed in 24 hours if the title has not been updated.**',
|
||||||
|
].join('\n'),
|
||||||
});
|
});
|
||||||
|
|
||||||
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))) {
|
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
|
||||||
await github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: context.payload.issue.number,
|
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."
|
body: [
|
||||||
|
'## Wrong place for feature requests',
|
||||||
|
'',
|
||||||
|
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||||
|
'',
|
||||||
|
'This issue has been closed. Feel free to re-submit your idea in the right place!',
|
||||||
|
].join('\n'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await github.rest.issues.update({
|
await github.rest.issues.update({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: context.payload.issue.number,
|
issue_number: context.payload.issue.number,
|
||||||
state: "closed",
|
state: 'closed',
|
||||||
state_reason: "not_planned"
|
state_reason: 'not_planned',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,14 +54,16 @@ jobs:
|
|||||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "$CURRENT → $NEW_VERSION ($BUMP)"
|
echo "$CURRENT → $NEW_VERSION ($BUMP)"
|
||||||
|
|
||||||
# Update both package.json files
|
# Update package.json files and Helm chart
|
||||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||||
|
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||||
|
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||||
|
|
||||||
# Commit and tag
|
# Commit and tag
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
|
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
|
||||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||||
git tag "v$NEW_VERSION"
|
git tag "v$NEW_VERSION"
|
||||||
git push origin main --follow-tags
|
git push origin main --follow-tags
|
||||||
@@ -151,3 +153,18 @@ jobs:
|
|||||||
|
|
||||||
- name: Inspect manifest
|
- name: Inspect manifest
|
||||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||||
|
|
||||||
|
release-helm:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: version-bump
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Publish Helm chart
|
||||||
|
uses: stefanprodan/helm-gh-pages@v1.7.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
charts_dir: charts
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
name: Enforce PR Target Branch
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, edited, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-target:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Flag or clear wrong base branch
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const base = context.payload.pull_request.base.ref;
|
||||||
|
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||||
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
// If the base was fixed, remove the label and let it through
|
||||||
|
if (base !== 'main') {
|
||||||
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base is main — check if this user is a maintainer
|
||||||
|
let permission = 'none';
|
||||||
|
try {
|
||||||
|
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
username: context.payload.pull_request.user.login,
|
||||||
|
});
|
||||||
|
permission = data.permission;
|
||||||
|
} catch (_) {
|
||||||
|
// User is not a collaborator — treat as 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['admin', 'write'].includes(permission)) {
|
||||||
|
console.log(`User has '${permission}' permission, skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already labeled — avoid spamming on every push
|
||||||
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
core.setFailed("PR must target `dev`, not `main`.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the label exists
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
color: 'd73a4a',
|
||||||
|
description: 'PR is targeting the wrong base branch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
labels: ['wrong-base-branch'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body: [
|
||||||
|
'## Wrong target branch',
|
||||||
|
'',
|
||||||
|
'This PR targets `main`, but contributions must go through `dev` first.',
|
||||||
|
'',
|
||||||
|
'To fix this, click **Edit** next to the PR title and change the base branch to `dev`.',
|
||||||
|
'',
|
||||||
|
'**This PR will be automatically closed in 24 hours if the base branch has not been updated.**',
|
||||||
|
'',
|
||||||
|
'> _If you need to merge directly to `main`, contact a maintainer._',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
core.setFailed("PR must target `dev`, not `main`.");
|
||||||
+4
-15
@@ -9,6 +9,8 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
|||||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||||
|
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||||
|
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
@@ -35,22 +37,9 @@ fix(maps): correct zoom level on Safari
|
|||||||
feat(budget): add CSV export for expenses
|
feat(budget): add CSV export for expenses
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Setup
|
## Development Environment
|
||||||
|
|
||||||
```bash
|
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||||
git clone https://github.com/mauriceboe/TREK.git
|
|
||||||
cd TREK
|
|
||||||
|
|
||||||
# Server
|
|
||||||
cd server && npm install && npm run dev
|
|
||||||
|
|
||||||
# Client (separate terminal)
|
|
||||||
cd client && npm install && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Server: `http://localhost:3001` | Client: `http://localhost:5173`
|
|
||||||
|
|
||||||
On first run, check the server logs for the auto-generated admin credentials.
|
|
||||||
|
|
||||||
## More Details
|
## More Details
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
||||||
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
||||||
@@ -143,9 +143,9 @@ services:
|
|||||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
|
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||||
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||||
- APP_URL=${APP_URL:-} # 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 email notifications and other external links
|
- APP_URL=${APP_URL:-} # 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 email notifications and other external links
|
||||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||||
@@ -161,6 +161,7 @@ services:
|
|||||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
# - 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
|
# - 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)
|
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||||
|
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
@@ -173,6 +174,14 @@ services:
|
|||||||
start_period: 15s
|
start_period: 15s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
|
||||||
|
|
||||||
|
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
|
||||||
|
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
|
||||||
|
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
|
||||||
|
|
||||||
|
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
@@ -282,9 +291,9 @@ trek.yourdomain.com {
|
|||||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
|
||||||
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
||||||
| **OIDC / SSO** | | |
|
| **OIDC / SSO** | | |
|
||||||
@@ -303,6 +312,7 @@ trek.yourdomain.com {
|
|||||||
| **Other** | | |
|
| **Other** | | |
|
||||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
|
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
|
||||||
|
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` |
|
||||||
|
|
||||||
## Optional API Keys
|
## Optional API Keys
|
||||||
|
|
||||||
|
|||||||
@@ -32,5 +32,7 @@ See `values.yaml` for more options.
|
|||||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||||
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
|
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||||
|
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
|
||||||
|
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 0.1.0
|
version: 2.9.13
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "latest"
|
appVersion: "2.9.13"
|
||||||
@@ -27,7 +27,7 @@ spec:
|
|||||||
fsGroup: 1000
|
fsGroup: 1000
|
||||||
containers:
|
containers:
|
||||||
- name: trek
|
- name: trek
|
||||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
{{- with .Values.resources }}
|
{{- with .Values.resources }}
|
||||||
resources:
|
resources:
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
image:
|
image:
|
||||||
repository: mauriceboe/trek
|
repository: mauriceboe/trek
|
||||||
tag: latest
|
# tag: latest
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
# Optional image pull secrets for private registries
|
# Optional image pull secrets for private registries
|
||||||
@@ -25,11 +25,11 @@ env:
|
|||||||
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
# 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.
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
# FORCE_HTTPS: "false"
|
# FORCE_HTTPS: "false"
|
||||||
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
|
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
|
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||||
# ALLOW_INTERNAL_NETWORK: "false"
|
# ALLOW_INTERNAL_NETWORK: "false"
|
||||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
# 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.
|
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||||
@@ -53,6 +53,8 @@ env:
|
|||||||
# Enable demo mode (hourly data resets).
|
# Enable demo mode (hourly data resets).
|
||||||
# MCP_RATE_LIMIT: "60"
|
# MCP_RATE_LIMIT: "60"
|
||||||
# Max MCP API requests per user per minute. Defaults to 60.
|
# Max MCP API requests per user per minute. Defaults to 60.
|
||||||
|
# MCP_MAX_SESSION_PER_USER: "5"
|
||||||
|
# Max concurrent MCP sessions per user. Defaults to 5.
|
||||||
|
|
||||||
|
|
||||||
# Secret environment variables stored in a Kubernetes Secret.
|
# Secret environment variables stored in a Kubernetes Secret.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.8.4",
|
"version": "2.9.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.8.4",
|
"version": "2.9.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.8.4",
|
"version": "2.9.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ export default function App() {
|
|||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!location.pathname.startsWith('/shared/')) {
|
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) {
|
||||||
loadUser()
|
loadUser()
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export const reservationsApi = {
|
|||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data),
|
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
navigate('/login')
|
navigate('/login', { state: { noRedirect: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = () => {
|
||||||
|
|||||||
@@ -956,6 +956,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
setLightboxUserId(photo.user_id)
|
setLightboxUserId(photo.user_id)
|
||||||
setLightboxInfo(null)
|
setLightboxInfo(null)
|
||||||
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
|
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
|
||||||
|
setLightboxInfoLoading(true)
|
||||||
|
apiClient.get(buildProviderAssetUrl(photo, 'info'))
|
||||||
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const exifContent = lightboxInfo ? (
|
const exifContent = lightboxInfo ? (
|
||||||
|
|||||||
@@ -248,8 +248,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const getTransportForDay = (dayId: number) => {
|
const getTransportForDay = (dayId: number) => {
|
||||||
const day = days.find(d => d.id === dayId)
|
const day = days.find(d => d.id === dayId)
|
||||||
if (!day?.date) return []
|
if (!day?.date) return []
|
||||||
|
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||||
return reservations.filter(r => {
|
return reservations.filter(r => {
|
||||||
if (!r.reservation_time || r.type === 'hotel') return false
|
if (!r.reservation_time || r.type === 'hotel') return false
|
||||||
|
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||||
const startDate = r.reservation_time.split('T')[0]
|
const startDate = r.reservation_time.split('T')[0]
|
||||||
const endDate = getEndDate(r)
|
const endDate = getEndDate(r)
|
||||||
|
|
||||||
@@ -341,14 +343,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
initTransportPositions(dayId)
|
initTransportPositions(dayId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order
|
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
||||||
// Places keep their order_index ordering — only transports are inserted based on time.
|
|
||||||
const baseItems = [
|
const baseItems = [
|
||||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
||||||
].sort((a, b) => a.sortKey - b.sortKey)
|
].sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
|
||||||
// Only transports are inserted among base items based on time/position
|
// Transports are inserted among places based on time
|
||||||
const timedTransports = transport.map(r => ({
|
const timedTransports = transport.map(r => ({
|
||||||
type: 'transport' as const,
|
type: 'transport' as const,
|
||||||
data: r,
|
data: r,
|
||||||
@@ -360,19 +361,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert transports among base items using persisted position or time-to-position mapping.
|
// Insert transports among places based on per-day position or time
|
||||||
const result = [...baseItems]
|
const result = [...baseItems]
|
||||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||||
const timed = timedTransports[ti]
|
const timed = timedTransports[ti]
|
||||||
const minutes = timed.minutes
|
const minutes = timed.minutes
|
||||||
|
|
||||||
// Use persisted position if available
|
// Use per-day position if explicitly set by user reorder
|
||||||
if (timed.data.day_plan_position != null) {
|
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||||
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data })
|
if (perDayPos != null) {
|
||||||
|
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find insertion position: after the last base item with time <= this transport's time
|
// Find insertion position: after the last place with time <= this transport's time
|
||||||
let insertAfterKey = -Infinity
|
let insertAfterKey = -Infinity
|
||||||
for (const item of result) {
|
for (const item of result) {
|
||||||
if (item.type === 'place') {
|
if (item.type === 'place') {
|
||||||
@@ -500,10 +502,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
if (transportUpdates.length) {
|
if (transportUpdates.length) {
|
||||||
for (const tu of transportUpdates) {
|
for (const tu of transportUpdates) {
|
||||||
const res = reservations.find(r => r.id === tu.id)
|
const res = reservations.find(r => r.id === tu.id)
|
||||||
if (res) res.day_plan_position = tu.day_plan_position
|
if (res) {
|
||||||
|
res.day_plan_position = tu.day_plan_position
|
||||||
|
// Update per-day position for multi-day reservations
|
||||||
|
if (!res.day_positions) res.day_positions = {}
|
||||||
|
res.day_positions[dayId] = tu.day_plan_position
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setTransportPosVersion(v => v + 1)
|
setTransportPosVersion(v => v + 1)
|
||||||
await reservationsApi.updatePositions(tripId, transportUpdates)
|
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
|
||||||
}
|
}
|
||||||
if (prevAssignmentIds.length) {
|
if (prevAssignmentIds.length) {
|
||||||
const capturedDayId = dayId
|
const capturedDayId = dayId
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
@@ -9,6 +10,7 @@ import { useToast } from '../shared/Toast'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
@@ -71,6 +73,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
const budgetCategories = useMemo(() => {
|
const budgetCategories = useMemo(() => {
|
||||||
const cats = new Set<string>()
|
const cats = new Set<string>()
|
||||||
@@ -111,6 +114,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
if (rawEnd.includes('T')) {
|
if (rawEnd.includes('T')) {
|
||||||
endDate = rawEnd.split('T')[0]
|
endDate = rawEnd.split('T')[0]
|
||||||
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
|
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
|
||||||
|
} else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) {
|
||||||
|
endDate = rawEnd
|
||||||
|
endTime = ''
|
||||||
}
|
}
|
||||||
setForm({
|
setForm({
|
||||||
title: reservation.title || '',
|
title: reservation.title || '',
|
||||||
@@ -139,7 +145,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||||
price: meta.price || '',
|
price: meta.price || '',
|
||||||
budget_category: meta.budget_category || '',
|
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
@@ -164,6 +170,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const startDate = form.reservation_time.split('T')[0]
|
const startDate = form.reservation_time.split('T')[0]
|
||||||
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
||||||
const endTime = form.reservation_end_time || '00:00'
|
const endTime = form.reservation_end_time || '00:00'
|
||||||
|
// For flights, compare in UTC using timezone offsets
|
||||||
|
if (form.type === 'flight') {
|
||||||
|
const parseOffset = (tz: string): number | null => {
|
||||||
|
if (!tz) return null
|
||||||
|
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
|
||||||
|
if (!m) return null
|
||||||
|
const sign = m[1] === '+' ? 1 : -1
|
||||||
|
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
|
||||||
|
}
|
||||||
|
const depOffset = parseOffset(form.meta_departure_timezone)
|
||||||
|
const arrOffset = parseOffset(form.meta_arrival_timezone)
|
||||||
|
if (depOffset === null || arrOffset === null) return false
|
||||||
|
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
|
||||||
|
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
|
||||||
|
return arrMinutes <= depMinutes
|
||||||
|
}
|
||||||
const startFull = `${startDate}T${startTime}`
|
const startFull = `${startDate}T${startTime}`
|
||||||
const endFull = `${form.end_date}T${endTime}`
|
const endFull = `${form.end_date}T${endTime}`
|
||||||
return endFull <= startFull
|
return endFull <= startFull
|
||||||
@@ -196,11 +218,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
if (form.end_date) {
|
if (form.end_date) {
|
||||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||||
}
|
}
|
||||||
if (form.price) metadata.price = form.price
|
if (isBudgetEnabled) {
|
||||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
if (form.price) metadata.price = form.price
|
||||||
|
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||||
|
}
|
||||||
const saveData: Record<string, any> = {
|
const saveData: Record<string, any> = {
|
||||||
title: form.title, type: form.type, status: form.status,
|
title: form.title, type: form.type, status: form.status,
|
||||||
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
|
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
|
||||||
|
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
|
||||||
location: form.location, confirmation_number: form.confirmation_number,
|
location: form.location, confirmation_number: form.confirmation_number,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
assignment_id: form.assignment_id || null,
|
assignment_id: form.assignment_id || null,
|
||||||
@@ -208,9 +233,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||||
}
|
}
|
||||||
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
||||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
if (isBudgetEnabled) {
|
||||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||||
: { total_price: 0 }
|
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||||
|
: { total_price: 0 }
|
||||||
|
}
|
||||||
// If hotel with place + days, pass hotel data for auto-creation or update
|
// If hotel with place + days, pass hotel data for auto-creation or update
|
||||||
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||||
saveData.create_accommodation = {
|
saveData.create_accommodation = {
|
||||||
@@ -358,7 +385,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||||
onChange={t => {
|
onChange={t => {
|
||||||
const [d] = (form.reservation_time || '').split('T')
|
const [d] = (form.reservation_time || '').split('T')
|
||||||
const date = d || new Date().toISOString().split('T')[0]
|
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||||
|
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||||
set('reservation_time', t ? `${date}T${t}` : date)
|
set('reservation_time', t ? `${date}T${t}` : date)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -559,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
<a href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||||
<button type="button" onClick={async () => {
|
<button type="button" onClick={async () => {
|
||||||
// Always unlink, never delete the file
|
// Always unlink, never delete the file
|
||||||
// Clear primary reservation_id if it points to this reservation
|
// Clear primary reservation_id if it points to this reservation
|
||||||
@@ -643,33 +671,37 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price + Budget Category */}
|
{/* Price + Budget Category — only shown when budget addon is enabled */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
{isBudgetEnabled && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<>
|
||||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<input type="text" inputMode="decimal" value={form.price}
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
|
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||||
placeholder="0.00"
|
<input type="text" inputMode="decimal" value={form.price}
|
||||||
style={inputStyle} />
|
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
|
||||||
</div>
|
placeholder="0.00"
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
style={inputStyle} />
|
||||||
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
|
</div>
|
||||||
<CustomSelect
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
value={form.budget_category}
|
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
|
||||||
onChange={v => set('budget_category', v)}
|
<CustomSelect
|
||||||
options={[
|
value={form.budget_category}
|
||||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
onChange={v => set('budget_category', v)}
|
||||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
options={[
|
||||||
]}
|
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||||
placeholder={t('reservations.budgetCategoryAuto')}
|
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||||
size="sm"
|
]}
|
||||||
/>
|
placeholder={t('reservations.budgetCategoryAuto')}
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
/>
|
||||||
{form.price && parseFloat(form.price) > 0 && (
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
|
</div>
|
||||||
{t('reservations.budgetHint')}
|
{form.price && parseFloat(form.price) > 0 && (
|
||||||
</div>
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
|
||||||
|
{t('reservations.budgetHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
interface AssignmentLookupEntry {
|
interface AssignmentLookupEntry {
|
||||||
@@ -138,7 +139,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
{fmtDate(r.reservation_time)}
|
{fmtDate(r.reservation_time)}
|
||||||
{r.reservation_end_time?.includes('T') && r.reservation_end_time.split('T')[0] !== r.reservation_time.split('T')[0] && (
|
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||||
<> – {fmtDate(r.reservation_end_time)}</>
|
<> – {fmtDate(r.reservation_end_time)}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
||||||
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{attachedFiles.map(f => (
|
{attachedFiles.map(f => (
|
||||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
<a key={f.id} href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||||
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ export default function AccountTab(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await authApi.deleteOwnAccount()
|
await authApi.deleteOwnAccount()
|
||||||
logout()
|
logout()
|
||||||
navigate('/login')
|
navigate('/login', { state: { noRedirect: true } })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
setShowDeleteConfirm(false)
|
setShowDeleteConfirm(false)
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
|
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
|
||||||
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
|
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
|
||||||
'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
|
'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
|
||||||
|
'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.',
|
||||||
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
|
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
|
||||||
'login.mfaTitle': 'المصادقة الثنائية',
|
'login.mfaTitle': 'المصادقة الثنائية',
|
||||||
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.demoFailed': 'Falha no login de demonstração',
|
'login.demoFailed': 'Falha no login de demonstração',
|
||||||
'login.oidcSignIn': 'Entrar com {name}',
|
'login.oidcSignIn': 'Entrar com {name}',
|
||||||
'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.',
|
'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.',
|
||||||
|
'login.oidcLoggedOut': 'Você foi desconectado. Entre novamente usando o provedor SSO.',
|
||||||
'login.demoHint': 'Experimente a demonstração — sem cadastro',
|
'login.demoHint': 'Experimente a demonstração — sem cadastro',
|
||||||
'login.mfaTitle': 'Autenticação em duas etapas',
|
'login.mfaTitle': 'Autenticação em duas etapas',
|
||||||
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
|
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.demoFailed': 'Přihlášení do dema se nezdařilo',
|
'login.demoFailed': 'Přihlášení do dema se nezdařilo',
|
||||||
'login.oidcSignIn': 'Přihlásit se přes {name}',
|
'login.oidcSignIn': 'Přihlásit se přes {name}',
|
||||||
'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.',
|
'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.',
|
||||||
|
'login.oidcLoggedOut': 'Byl jste odhlášen. Přihlaste se znovu přes SSO poskytovatele.',
|
||||||
'login.demoHint': 'Vyzkoušejte demo – registrace není nutná',
|
'login.demoHint': 'Vyzkoušejte demo – registrace není nutná',
|
||||||
'login.mfaTitle': 'Dvoufaktorové ověření',
|
'login.mfaTitle': 'Dvoufaktorové ověření',
|
||||||
'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.',
|
'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.',
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.demoFailed': 'Demo-Login fehlgeschlagen',
|
'login.demoFailed': 'Demo-Login fehlgeschlagen',
|
||||||
'login.oidcSignIn': 'Anmelden mit {name}',
|
'login.oidcSignIn': 'Anmelden mit {name}',
|
||||||
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
|
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
|
||||||
|
'login.oidcLoggedOut': 'Du wurdest abgemeldet. Melde dich erneut über deinen SSO-Anbieter an.',
|
||||||
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
|
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
|
||||||
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
|
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
|
||||||
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
|
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.demoFailed': 'Demo login failed',
|
'login.demoFailed': 'Demo login failed',
|
||||||
'login.oidcSignIn': 'Sign in with {name}',
|
'login.oidcSignIn': 'Sign in with {name}',
|
||||||
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
|
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
|
||||||
|
'login.oidcLoggedOut': 'You have been logged out. Sign in again using your SSO provider.',
|
||||||
'login.demoHint': 'Try the demo — no registration needed',
|
'login.demoHint': 'Try the demo — no registration needed',
|
||||||
'login.mfaTitle': 'Two-factor authentication',
|
'login.mfaTitle': 'Two-factor authentication',
|
||||||
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
|
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
|
||||||
|
|||||||
@@ -1490,6 +1490,7 @@ const es: Record<string, string> = {
|
|||||||
'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña',
|
'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña',
|
||||||
'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.',
|
'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.',
|
||||||
'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
|
'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
|
||||||
|
'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.',
|
||||||
|
|
||||||
// Settings (2.6.2)
|
// Settings (2.6.2)
|
||||||
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
|
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ const fr: Record<string, string> = {
|
|||||||
'login.demoFailed': 'Échec de la connexion démo',
|
'login.demoFailed': 'Échec de la connexion démo',
|
||||||
'login.oidcSignIn': 'Se connecter avec {name}',
|
'login.oidcSignIn': 'Se connecter avec {name}',
|
||||||
'login.oidcOnly': 'L\'authentification par mot de passe est désactivée. Veuillez vous connecter via votre fournisseur SSO.',
|
'login.oidcOnly': 'L\'authentification par mot de passe est désactivée. Veuillez vous connecter via votre fournisseur SSO.',
|
||||||
|
'login.oidcLoggedOut': 'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.',
|
||||||
'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
|
'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.demoFailed': 'Demo bejelentkezés sikertelen',
|
'login.demoFailed': 'Demo bejelentkezés sikertelen',
|
||||||
'login.oidcSignIn': 'Bejelentkezés ezzel: {name}',
|
'login.oidcSignIn': 'Bejelentkezés ezzel: {name}',
|
||||||
'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.',
|
'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.',
|
||||||
|
'login.oidcLoggedOut': 'Kijelentkeztél. Jelentkezz be újra az SSO szolgáltatódon keresztül.',
|
||||||
'login.demoHint': 'Próbáld ki a demót — regisztráció nélkül',
|
'login.demoHint': 'Próbáld ki a demót — regisztráció nélkül',
|
||||||
'login.mfaTitle': 'Kétfaktoros hitelesítés',
|
'login.mfaTitle': 'Kétfaktoros hitelesítés',
|
||||||
'login.mfaSubtitle': 'Add meg a 6 jegyű kódot a hitelesítő alkalmazásból.',
|
'login.mfaSubtitle': 'Add meg a 6 jegyű kódot a hitelesítő alkalmazásból.',
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.demoFailed': 'Accesso demo fallito',
|
'login.demoFailed': 'Accesso demo fallito',
|
||||||
'login.oidcSignIn': 'Accedi con {name}',
|
'login.oidcSignIn': 'Accedi con {name}',
|
||||||
'login.oidcOnly': 'L\'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.',
|
'login.oidcOnly': 'L\'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.',
|
||||||
|
'login.oidcLoggedOut': 'Sei stato disconnesso. Accedi nuovamente tramite il tuo provider SSO.',
|
||||||
'login.demoHint': 'Prova la demo — nessuna registrazione necessaria',
|
'login.demoHint': 'Prova la demo — nessuna registrazione necessaria',
|
||||||
'login.mfaTitle': 'Autenticazione a due fattori',
|
'login.mfaTitle': 'Autenticazione a due fattori',
|
||||||
'login.mfaSubtitle': 'Inserisci il codice a 6 cifre dalla tua app authenticator.',
|
'login.mfaSubtitle': 'Inserisci il codice a 6 cifre dalla tua app authenticator.',
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ const nl: Record<string, string> = {
|
|||||||
'login.demoFailed': 'Demo-login mislukt',
|
'login.demoFailed': 'Demo-login mislukt',
|
||||||
'login.oidcSignIn': 'Inloggen met {name}',
|
'login.oidcSignIn': 'Inloggen met {name}',
|
||||||
'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.',
|
'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.',
|
||||||
|
'login.oidcLoggedOut': 'Je bent uitgelogd. Log opnieuw in via je SSO-provider.',
|
||||||
'login.demoHint': 'Probeer de demo — geen registratie nodig',
|
'login.demoHint': 'Probeer de demo — geen registratie nodig',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.demoFailed': 'Nie udało się zalogować do wersji demonstracyjnej',
|
'login.demoFailed': 'Nie udało się zalogować do wersji demonstracyjnej',
|
||||||
'login.oidcSignIn': 'Zaloguj się z {name}',
|
'login.oidcSignIn': 'Zaloguj się z {name}',
|
||||||
'login.oidcOnly': 'Uwierzytelnianie hasłem jest wyłączone. Zaloguj się za pomocą swojego dostawcy SSO.',
|
'login.oidcOnly': 'Uwierzytelnianie hasłem jest wyłączone. Zaloguj się za pomocą swojego dostawcy SSO.',
|
||||||
|
'login.oidcLoggedOut': 'Zostałeś wylogowany. Zaloguj się ponownie za pomocą swojego dostawcy SSO.',
|
||||||
'login.demoHint': 'Wypróbuj demo — nie wymaga rejestracji',
|
'login.demoHint': 'Wypróbuj demo — nie wymaga rejestracji',
|
||||||
'login.mfaTitle': 'Uwierzytelnianie dwuskładnikowe',
|
'login.mfaTitle': 'Uwierzytelnianie dwuskładnikowe',
|
||||||
'login.mfaSubtitle': 'Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.',
|
'login.mfaSubtitle': 'Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.',
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ const ru: Record<string, string> = {
|
|||||||
'login.demoFailed': 'Ошибка демо-входа',
|
'login.demoFailed': 'Ошибка демо-входа',
|
||||||
'login.oidcSignIn': 'Войти через {name}',
|
'login.oidcSignIn': 'Войти через {name}',
|
||||||
'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.',
|
'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.',
|
||||||
|
'login.oidcLoggedOut': 'Вы вышли из системы. Войдите снова через вашего провайдера SSO.',
|
||||||
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
|
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ const zh: Record<string, string> = {
|
|||||||
'login.demoFailed': '演示登录失败',
|
'login.demoFailed': '演示登录失败',
|
||||||
'login.oidcSignIn': '通过 {name} 登录',
|
'login.oidcSignIn': '通过 {name} 登录',
|
||||||
'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
|
'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
|
||||||
|
'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。',
|
||||||
'login.demoHint': '试用演示——无需注册',
|
'login.demoHint': '试用演示——无需注册',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'login.demoFailed': '演示登入失敗',
|
'login.demoFailed': '演示登入失敗',
|
||||||
'login.oidcSignIn': '透過 {name} 登入',
|
'login.oidcSignIn': '透過 {name} 登入',
|
||||||
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
|
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
|
||||||
|
'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。',
|
||||||
'login.demoHint': '試用演示——無需註冊',
|
'login.demoHint': '試用演示——無需註冊',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
|
|||||||
@@ -1551,7 +1551,7 @@ docker run -d --name trek \\
|
|||||||
await adminApi.rotateJwtSecret()
|
await adminApi.rotateJwtSecret()
|
||||||
setShowRotateJwtModal(false)
|
setShowRotateJwtModal(false)
|
||||||
logout()
|
logout()
|
||||||
navigate('/login')
|
navigate('/login', { state: { noRedirect: true } })
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
setRotatingJwt(false)
|
setRotatingJwt(false)
|
||||||
|
|||||||
@@ -480,15 +480,13 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match feature by ISO code OR region name
|
// Match feature by ISO code OR region name (native or English)
|
||||||
const isVisitedFeature = (f: any) => {
|
const isVisitedFeature = (f: any) => {
|
||||||
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
|
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
|
||||||
const name = (f.properties?.name || '').toLowerCase()
|
const name = (f.properties?.name || '').toLowerCase()
|
||||||
if (visitedRegionNames.has(name)) return true
|
if (visitedRegionNames.has(name)) return true
|
||||||
// Fuzzy: check if any visited name is contained in feature name or vice versa
|
const nameEn = (f.properties?.name_en || '').toLowerCase()
|
||||||
for (const vn of visitedRegionNames) {
|
if (nameEn && visitedRegionNames.has(nameEn)) return true
|
||||||
if (name.includes(vn) || vn.includes(name)) return true
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,15 +533,16 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
},
|
},
|
||||||
onEachFeature: (feature, layer) => {
|
onEachFeature: (feature, layer) => {
|
||||||
const regionName = feature?.properties?.name || ''
|
const regionName = feature?.properties?.name || ''
|
||||||
|
const regionNameEn = feature?.properties?.name_en || ''
|
||||||
const countryName = feature?.properties?.admin || ''
|
const countryName = feature?.properties?.admin || ''
|
||||||
const regionCode = feature?.properties?.iso_3166_2 || ''
|
const regionCode = feature?.properties?.iso_3166_2 || ''
|
||||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||||
const visited = isVisitedFeature(feature)
|
const visited = isVisitedFeature(feature)
|
||||||
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0
|
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || regionPlaceCounts[regionNameEn.toLowerCase()] || 0
|
||||||
layer.on('click', () => {
|
layer.on('click', () => {
|
||||||
if (!countryA2) return
|
if (!countryA2) return
|
||||||
if (visited) {
|
if (visited) {
|
||||||
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
|
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
|
||||||
if (regionEntry?.manuallyMarked) {
|
if (regionEntry?.manuallyMarked) {
|
||||||
setConfirmActionRef.current({
|
setConfirmActionRef.current({
|
||||||
type: 'unmark-region',
|
type: 'unmark-region',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react'
|
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
@@ -29,10 +29,13 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
||||||
const [inviteToken, setInviteToken] = useState<string>('')
|
const [inviteToken, setInviteToken] = useState<string>('')
|
||||||
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
||||||
|
const exchangeInitiated = useRef(false)
|
||||||
|
|
||||||
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
|
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
|
||||||
const { setLanguageLocal } = useSettingsStore()
|
const { setLanguageLocal } = useSettingsStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect
|
||||||
|
|
||||||
const redirectTarget = useMemo(() => {
|
const redirectTarget = useMemo(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
@@ -63,11 +66,13 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (oidcCode) {
|
if (oidcCode) {
|
||||||
|
if (exchangeInitiated.current) return
|
||||||
|
exchangeInitiated.current = true
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
window.history.replaceState({}, '', '/login')
|
|
||||||
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
|
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(async data => {
|
.then(async data => {
|
||||||
|
window.history.replaceState({}, '', '/login')
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
await loadUser()
|
await loadUser()
|
||||||
navigate('/dashboard', { replace: true })
|
navigate('/dashboard', { replace: true })
|
||||||
@@ -75,7 +80,10 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
setError(data.error || 'OIDC login failed')
|
setError(data.error || 'OIDC login failed')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setError('OIDC login failed'))
|
.catch(() => {
|
||||||
|
window.history.replaceState({}, '', '/login')
|
||||||
|
setError('OIDC login failed')
|
||||||
|
})
|
||||||
.finally(() => setIsLoading(false))
|
.finally(() => setIsLoading(false))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -96,12 +104,12 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
if (config) {
|
if (config) {
|
||||||
setAppConfig(config)
|
setAppConfig(config)
|
||||||
if (!config.has_users) setMode('register')
|
if (!config.has_users) setMode('register')
|
||||||
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) {
|
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||||
window.location.href = '/api/auth/oidc/login'
|
window.location.href = '/api/auth/oidc/login'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [navigate, t])
|
}, [navigate, t, noRedirect])
|
||||||
|
|
||||||
const handleDemoLogin = async (): Promise<void> => {
|
const handleDemoLogin = async (): Promise<void> => {
|
||||||
setError('')
|
setError('')
|
||||||
@@ -527,7 +535,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
{oidcOnly ? (
|
{oidcOnly ? (
|
||||||
<>
|
<>
|
||||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
|
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
|
||||||
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{t('login.oidcOnly')}</p>
|
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{noRedirect ? t('login.oidcLoggedOut') : t('login.oidcOnly')}</p>
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
return saved || 'plan'
|
return saved || 'plan'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const validTabIds = TRIP_TABS.map(t => t.id)
|
||||||
|
if (!validTabIds.includes(activeTab)) {
|
||||||
|
setActiveTab('plan')
|
||||||
|
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
|
||||||
|
}
|
||||||
|
}, [enabledAddons])
|
||||||
|
|
||||||
const handleTabChange = (tabId: string): void => {
|
const handleTabChange = (tabId: string): void => {
|
||||||
setActiveTab(tabId)
|
setActiveTab(tabId)
|
||||||
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
|
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
|
||||||
@@ -423,7 +431,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const handleSaveReservation = async (data) => {
|
const handleSaveReservation = async (data) => {
|
||||||
try {
|
try {
|
||||||
if (editingReservation) {
|
if (editingReservation) {
|
||||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
if (data.type === 'hotel') {
|
if (data.type === 'hotel') {
|
||||||
|
|||||||
+5
-4
@@ -22,10 +22,10 @@ services:
|
|||||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
|
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||||
- TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP)
|
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||||
- ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||||
# - APP_URL=https://trek.example.com # Public base URL — required when OIDC is enabled (must match the redirect URI registered with your IdP); also used as base URL for links in email notifications
|
# - APP_URL=https://trek.example.com # Public base URL — required when OIDC is enabled (must match the redirect URI registered with your IdP); also used as base URL for links in email notifications
|
||||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||||
@@ -39,6 +39,7 @@ services:
|
|||||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
# - 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
|
# - 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)
|
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||||
|
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
|||||||
+4
-3
@@ -9,9 +9,9 @@ TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
|||||||
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
||||||
|
|
||||||
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
||||||
FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy
|
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
|
||||||
COOKIE_SECURE=true # Set to false to allow session cookies over HTTP (e.g. plain-IP or non-HTTPS setups). Defaults to true in production.
|
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
|
||||||
TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
|
||||||
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
||||||
|
|
||||||
APP_URL=https://trek.example.com # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP
|
APP_URL=https://trek.example.com # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP
|
||||||
@@ -29,6 +29,7 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
|
|||||||
DEMO_MODE=false # Demo mode - resets data hourly
|
DEMO_MODE=false # Demo mode - resets data hourly
|
||||||
|
|
||||||
# MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
# MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||||
|
# MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
|
||||||
|
|
||||||
# Initial admin account — only used on first boot when no users exist yet.
|
# Initial admin account — only used on first boot when no users exist yet.
|
||||||
# If both are set the admin account is created with these credentials.
|
# If both are set the admin account is created with these credentials.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.8.4",
|
"version": "2.9.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.8.4",
|
"version": "2.9.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.8.4",
|
"version": "2.9.13",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ export function createApp(): express.Application {
|
|||||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
||||||
"https://router.project-osrm.org/route/v1"
|
"https://router.project-osrm.org/route/v1/"
|
||||||
],
|
],
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||||
objectSrc: ["'none'"],
|
objectSrc: ["'none'"],
|
||||||
|
|||||||
@@ -843,6 +843,27 @@ function runMigrations(db: Database.Database): void {
|
|||||||
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
||||||
for (const b of bagsWithUser) ins.run(b.id, b.user_id);
|
for (const b of bagsWithUser) ins.run(b.id, b.user_id);
|
||||||
},
|
},
|
||||||
|
// Migration: Per-day positions for multi-day reservations
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reservation_day_positions (
|
||||||
|
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
|
||||||
|
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||||
|
position REAL NOT NULL,
|
||||||
|
PRIMARY KEY (reservation_id, day_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
// Migrate existing global positions to per-day entries
|
||||||
|
const reservations = db.prepare('SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL').all() as any[];
|
||||||
|
const ins = db.prepare('INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
|
||||||
|
for (const r of reservations) {
|
||||||
|
const startDate = r.reservation_time?.split('T')[0];
|
||||||
|
const endDate = r.reservation_end_time?.split('T')[0] || startDate;
|
||||||
|
if (!startDate) continue;
|
||||||
|
const matchingDays = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?').all(r.trip_id, startDate, endDate) as { id: number }[];
|
||||||
|
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ interface McpSession {
|
|||||||
const sessions = new Map<string, McpSession>();
|
const sessions = new Map<string, McpSession>();
|
||||||
|
|
||||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const MAX_SESSIONS_PER_USER = 5;
|
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
||||||
|
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 5;
|
||||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||||
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
|
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
|
||||||
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
|
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
|
||||||
|
|||||||
@@ -91,10 +91,11 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
|
|||||||
|
|
||||||
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
|
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
|
||||||
|
|
||||||
updatePositions(tripId, positions);
|
const { day_id } = req.body;
|
||||||
|
updatePositions(tripId, positions, day_id);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'reservation:positions', { positions, day_id }, req.headers['x-socket-id'] as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -168,6 +168,34 @@ export function getParticipants(assignmentId: string | number) {
|
|||||||
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
|
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
|
||||||
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
||||||
.run(placeTime ?? null, endTime ?? null, id);
|
.run(placeTime ?? null, endTime ?? null, id);
|
||||||
|
|
||||||
|
// Auto-sort: reorder timed assignments chronologically within the day
|
||||||
|
if (placeTime) {
|
||||||
|
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as { day_id: number } | undefined;
|
||||||
|
if (assignment) {
|
||||||
|
const dayAssignments = db.prepare(`
|
||||||
|
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
|
||||||
|
FROM day_assignments da
|
||||||
|
JOIN places p ON da.place_id = p.id
|
||||||
|
WHERE da.day_id = ?
|
||||||
|
ORDER BY da.order_index ASC
|
||||||
|
`).all(assignment.day_id) as { id: number; effective_time: string | null }[];
|
||||||
|
|
||||||
|
// Separate timed and untimed, sort timed by time
|
||||||
|
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
|
||||||
|
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
|
||||||
|
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
|
||||||
|
return ta.localeCompare(tb);
|
||||||
|
});
|
||||||
|
const untimed = dayAssignments.filter(a => !a.effective_time);
|
||||||
|
|
||||||
|
// Interleave: timed in chronological order, untimed keep relative position
|
||||||
|
const reordered = [...timed, ...untimed];
|
||||||
|
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
|
||||||
|
reordered.forEach((a, i) => update.run(i, a.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return getAssignmentWithPlace(Number(id));
|
return getAssignmentWithPlace(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInf
|
|||||||
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
||||||
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
||||||
}
|
}
|
||||||
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
|
const regionName = data.address?.state || data.address?.province || data.address?.region || data.address?.county || data.address?.city || null;
|
||||||
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
||||||
const info: RegionInfo = {
|
const info: RegionInfo = {
|
||||||
country_code: countryCode,
|
country_code: countryCode,
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
|
|||||||
await pipeline(Readable.fromWeb(resp.body as any), response);
|
await pipeline(Readable.fromWeb(resp.body as any), response);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (response.headersSent) return;
|
if (response.headersSent) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (error instanceof SsrfBlockedError) {
|
if (error instanceof SsrfBlockedError) {
|
||||||
response.status(400).json({ error: error.message });
|
response.status(400).json({ error: error.message });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listReservations(tripId: string | number) {
|
export function listReservations(tripId: string | number) {
|
||||||
return db.prepare(`
|
const reservations = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
|
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
@@ -16,7 +16,27 @@ export function listReservations(tripId: string | number) {
|
|||||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||||
WHERE r.trip_id = ?
|
WHERE r.trip_id = ?
|
||||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||||
`).all(tripId);
|
`).all(tripId) as any[];
|
||||||
|
|
||||||
|
// Attach per-day positions for multi-day reservations
|
||||||
|
const dayPositions = db.prepare(`
|
||||||
|
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||||
|
FROM reservation_day_positions rdp
|
||||||
|
JOIN reservations r ON rdp.reservation_id = r.id
|
||||||
|
WHERE r.trip_id = ?
|
||||||
|
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
|
||||||
|
|
||||||
|
const posMap = new Map<number, Record<number, number>>();
|
||||||
|
for (const dp of dayPositions) {
|
||||||
|
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||||
|
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of reservations) {
|
||||||
|
r.day_positions = posMap.get(r.id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reservations;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReservationWithJoins(id: string | number) {
|
export function getReservationWithJoins(id: string | number) {
|
||||||
@@ -117,14 +137,35 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
|||||||
return { reservation, accommodationCreated };
|
return { reservation, accommodationCreated };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[]) {
|
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[], dayId?: number | string) {
|
||||||
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
|
if (dayId) {
|
||||||
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
|
// Per-day positions for multi-day reservations
|
||||||
for (const item of items) {
|
const stmt = db.prepare('INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
|
||||||
stmt.run(item.day_plan_position, item.id, tripId);
|
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
|
||||||
}
|
for (const item of items) {
|
||||||
});
|
stmt.run(item.id, dayId, item.day_plan_position);
|
||||||
updateMany(positions);
|
}
|
||||||
|
});
|
||||||
|
updateMany(positions);
|
||||||
|
} else {
|
||||||
|
// Legacy: update global position
|
||||||
|
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
|
||||||
|
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
|
||||||
|
for (const item of items) {
|
||||||
|
stmt.run(item.day_plan_position, item.id, tripId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateMany(positions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayPositions(tripId: string | number, dayId: number | string) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT rdp.reservation_id, rdp.position
|
||||||
|
FROM reservation_day_positions rdp
|
||||||
|
JOIN reservations r ON rdp.reservation_id = r.id
|
||||||
|
WHERE r.trip_id = ? AND rdp.day_id = ?
|
||||||
|
`).all(tripId, dayId) as { reservation_id: number; position: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReservation(id: string | number, tripId: string | number) {
|
export function getReservation(id: string | number, tripId: string | number) {
|
||||||
@@ -193,8 +234,8 @@ export function updateReservation(id: string | number, tripId: string | number,
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
title || null,
|
title || null,
|
||||||
reservation_time !== undefined ? (reservation_time || null) : current.reservation_time,
|
(type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time),
|
||||||
reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time,
|
(type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time),
|
||||||
location !== undefined ? (location || null) : current.location,
|
location !== undefined ? (location || null) : current.location,
|
||||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||||
notes !== undefined ? (notes || null) : current.notes,
|
notes !== undefined ? (notes || null) : current.notes,
|
||||||
|
|||||||
@@ -202,3 +202,184 @@ describe('Bucket list', () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Mark/unmark region', () => {
|
||||||
|
it('ATLAS-009 — POST /region/:code/mark marks a region as visited', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-009 — POST /region/:code/mark without name returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ country_code: 'DE' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-009 — POST /region/:code/mark without country_code returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-009 — marking a region also auto-marks the parent country', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
const stats = await request(app)
|
||||||
|
.get('/api/addons/atlas/stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||||
|
expect(codes).toContain('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-009 — marking the same region twice is idempotent', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-010 — GET /regions returns marked regions grouped by country', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-BY/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Bayern', country_code: 'DE' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/regions')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('regions');
|
||||||
|
const deRegions = res.body.regions['DE'] as any[];
|
||||||
|
expect(deRegions).toBeDefined();
|
||||||
|
const codes = deRegions.map((r: any) => r.code);
|
||||||
|
expect(codes).toContain('DE-NW');
|
||||||
|
expect(codes).toContain('DE-BY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-011 — DELETE /region/:code/mark unmarks a region', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/regions')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const deRegions = res.body.regions['DE'] as any[] | undefined;
|
||||||
|
const codes = (deRegions || []).map((r: any) => r.code);
|
||||||
|
expect(codes).not.toContain('DE-NW');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-011 — unmark last region in country also unmarks the parent country', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const stats = await request(app)
|
||||||
|
.get('/api/addons/atlas/stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||||
|
expect(codes).not.toContain('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-011 — unmark one region keeps country when another region remains', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-BY/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Bayern', country_code: 'DE' });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const stats = await request(app)
|
||||||
|
.get('/api/addons/atlas/stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||||
|
expect(codes).toContain('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-011 — regions are isolated between users', async () => {
|
||||||
|
const { user: user1 } = createUser(testDb);
|
||||||
|
const { user: user2 } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user1.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/regions')
|
||||||
|
.set('Cookie', authCookie(user2.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const deRegions = res.body.regions['DE'] as any[] | undefined;
|
||||||
|
expect(deRegions).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+4
-3
@@ -35,9 +35,9 @@
|
|||||||
<Config Name="LOG_LEVEL" Target="LOG_LEVEL" Default="info" Mode="" Description="Log verbosity: info = concise user actions, debug = verbose admin-level details." Type="Variable" Display="advanced" Required="false" Mask="false">info</Config>
|
<Config Name="LOG_LEVEL" Target="LOG_LEVEL" Default="info" Mode="" Description="Log verbosity: info = concise user actions, debug = verbose admin-level details." Type="Variable" Display="advanced" Required="false" Mask="false">info</Config>
|
||||||
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||||
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||||
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Redirect HTTP to HTTPS when TREK is behind a TLS-terminating reverse proxy." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Set to false to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Not recommended to disable in production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
||||||
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Number of trusted reverse proxies for X-Forwarded-For IP detection." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
||||||
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
|
|
||||||
<!-- Initial Setup -->
|
<!-- Initial Setup -->
|
||||||
@@ -58,4 +58,5 @@
|
|||||||
<!-- Other -->
|
<!-- Other -->
|
||||||
<Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
<Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config>
|
<Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config>
|
||||||
|
<Config Name="MCP_MAX_SESSION_PER_USER" Target="MCP_MAX_SESSION_PER_USER" Default="5" Mode="" Description="Max concurrent MCP sessions per user." Type="Variable" Display="advanced" Required="false" Mask="false">5</Config>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
Reference in New Issue
Block a user