diff --git a/README.md b/README.md index d6e7603..a910286 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,144 @@ -# AdventureLog: Embark, Explore, Remember. 🌍 +
-### _"Never forget an adventure with AdventureLog - Your ultimate travel companion!"_ + logo +

AdventureLog

+ +

+ The ultimate travel companion for the modern-day explorer. +

+ +

+ View Demo + · + Documentation + · + Discord + · + Support 💖 +

+
-[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/seanmorley15) +
-- **[Documentation](https://adventurelog.app)** -- **[Demo](https://demo.adventurelog.app)** -- **[Join the AdventureLog Community Discord Server](https://discord.gg/wRbQ9Egr8C)** + # Table of Contents -- [Installation](#installation) - - [Docker 🐋](#docker-) - - [Prerequisites](#prerequisites) - - [Getting Started](#getting-started) - - [Configuration](#configuration) - - [Frontend Container (web)](#frontend-container-web) - - [Backend Container (server)](#backend-container-server) - - [Proxy Container (nginx) Configuration](#proxy-container-nginx-configuration) - - [Running the Containers](#running-the-containers) -- [Screenshots 🖼️](#screenshots) -- [About AdventureLog](#about-adventurelog) -- [Attribution](#attribution) +- [About the Project](#-about-the-project) + - [Screenshots](#-screenshots) + - [Tech Stack](#-tech-stack) + - [Features](#-features) +- [Roadmap](#-roadmap) +- [Contributing](#-contributing) +- [License](#-license) +- [Contact](#-contact) +- [Acknowledgements](#-acknowledgements) -# Installation + -# Docker 🐋 +## ⭐ About the Project -Docker is the preferred way to run AdventureLog on your local machine. It is a lightweight containerization technology that allows you to run applications in isolated environments called containers. -**Note**: This guide mainly focuses on installation with a linux based host machine, but the steps are similar for other operating systems. +Starting from a simple idea of tracking travel locations (called adventures), AdventureLog has grown into a full-fledged travel companion. With AdventureLog, you can log your adventures, keep track of where you've been on the world map, plan your next trip collaboratively, and share your experiences with friends and family. -## Prerequisites +AdventureLog was created to solve a problem: the lack of a modern, open-source, user-friendly travel companion. Many existing travel apps are either too complex, too expensive, or too closed-off to be useful for the average traveler. AdventureLog aims to be the opposite: simple, beautiful, and open to everyone. -- Docker installed on your machine/server. You can learn how to download it [here](https://docs.docker.com/engine/install/). + -## Getting Started +### 📷 Screenshots -Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine: +
+ Adventures +

Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.

+ Adventure Details +

Shows specific details about an adventure, including the name, date, location, description, and rating.

+ Edit Modal + Adventure Details +

View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map

+ Dashboard +

Displays a summary of your adventures, including your world travel stats.

+ Itinerary +

Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.

+ Countries +

Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.

+ Regions +

Displays the regions for a specific country, includes a map view to visually select regions.

+
-```bash -wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml -``` + -## Configuration +### 🚀 Tech Stack -Here is a summary of the configuration options available in the `docker-compose.yml` file: +
+ Client + +
- +
+ Server + +
+ -### Frontend Container (web) +### 🎯 Features -| Name | Required | Description | Default Value | -| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| `PUBLIC_SERVER_URL` | Yes | What the frontend SSR server uses to connect to the backend. | http://server:8000 | -| `ORIGIN` | Sometimes | Not needed if using HTTPS. If not, set it to the domain of what you will acess the app from. | http://localhost:8015 | -| `BODY_SIZE_LIMIT` | Yes | Used to set the maximum upload size to the server. Should be changed to prevent someone from uploading too much! Custom values must be set in **kiliobytes**. | Infinity | +- **Track Your Adventures** 🌍: Log your adventures and keep track of where you've been on the world map. + - Adventures can store a variety of information, including the location, date, and description. + - Adventures can be sorted into custom categories for easy organization. + - Adventures can be marked as private or public, allowing you to share your adventures with friends and family. + - Keep track of the countries and regions you've visited with the world travel book. +- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner. + - Itineraries can be created for any number of days and can include multiple destinations. + - Itineraries include many planning features like flight information, notes, checklists, and links to external resources. + - Itineraries can be shared with friends and family for collaborative planning. +- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together. + - Adventures and itineraries can be shared via a public link or directly with other AdventureLog users. + - Collaborators can view and edit shared itineraries (collections), making planning a breeze. -### Backend Container (server) + -| Name | Required | Description | Default Value | -| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| `PGHOST` | Yes | Databse host. | db | -| `PGDATABASE` | Yes | Database. | database | -| `PGUSER` | Yes | Database user. | adventure | -| `PGPASSWORD` | Yes | Database password. | changeme123 | -| `DJANGO_ADMIN_USERNAME` | Yes | Default username. | admin | -| `DJANGO_ADMIN_PASSWORD` | Yes | Default password, change after inital login. | admin | -| `DJANGO_ADMIN_EMAIL` | Yes | Default user's email. | admin@example.com | -| `PUBLIC_URL` | Yes | This needs to match the outward port of the server and be accessible from where the app is used. It is used for the creation of image urls. | http://localhost:8016 | -| `CSRF_TRUSTED_ORIGINS` | Yes | Need to be changed to the orgins where you use your backend server and frontend. These values are comma seperated. | http://localhost:8016 | -| `FRONTEND_URL` | Yes | This is the publicly accessible url to the **frontend** container. This link should be accessible for all users. Used for email generation. | http://localhost:8015 | +## 🧭 Roadmap -## Running the Containers +The AdventureLog Roadmap can be found [here](https://github.com/users/seanmorley15/projects/5) -To start the containers, run the following command: + -```bash -docker compose up -d -``` +## 👋 Contributing -Enjoy AdventureLog! 🎉 + + + -# Screenshots +Contributions are always welcome! -![Adventure Page](brand/screenshots/adventures.png) -Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures. +See `contributing.md` for ways to get started. -![Detail Page](brand/screenshots/details.png) -Shows specific details about an adventure, including the name, date, location, description, and rating. + -![Edit](brand/screenshots/edit.png) +## 📃 License -![Map Page](brand/screenshots/map.png) -View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map. +Distributed under the GNU General Public License v3.0. See `LICENSE` for more information. -![Dashboard Page](brand/screenshots/dashboard.png) -Displays a summary of your adventures, including your world travel stats. + -![Itinerary Page](brand/screenshots/itinerary.png) -Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view. +## 🤝 Contact -![Country Page](brand/screenshots/countries.png) -Lists all the countries you have visited and plan to visit, with the ability to filter by visit status. +Sean Morley - [website](https://seanmorley.com) -![Region Page](brand/screenshots/regions.png) -Displays the regions for a specific country, includes a map view to visually select regions. +Hi! I'm Sean, the creator of AdventureLog. I'm a college student and software developer with a passion for travel and adventure. I created AdventureLog to help people like me document their adventures and plan new ones effortlessly. As a student, I am always looking for more opportunities to learn and grow, so feel free to reach out via the contact on my website if you would like to collaborate or chat! -# About AdventureLog + -AdventureLog is a Svelte Kit and Django application that utilizes a PostgreSQL database. Users can log the adventures they have experienced, as well as plan future ones. Key features include: - -- Logging past adventures with fields like name, date, location, description, and rating. -- Planning future adventures with similar fields. -- Tagging different activity types for better organization. -- Viewing countries, regions, and marking visited regions. - -AdventureLog aims to be your ultimate travel companion, helping you document your adventures and plan new ones effortlessly. - -AdventureLog is licensed under the GNU General Public License v3.0. - - - -# Attribution +## 💎 Acknowledgements - Logo Design by [nordtechtiger](https://github.com/nordtechtiger) - WorldTravel Dataset [dr5hn/countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 48adee3..50e034a 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -20,21 +20,45 @@ done python manage.py migrate # Create superuser if environment variables are set and there are no users present at all. -if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ]; then +if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ] && [ -n "$DJANGO_ADMIN_EMAIL" ]; then echo "Creating superuser..." python manage.py shell << EOF from django.contrib.auth import get_user_model +from allauth.account.models import EmailAddress + User = get_user_model() -if User.objects.count() == 0: - User.objects.create_superuser('$DJANGO_ADMIN_USERNAME', '$DJANGO_ADMIN_EMAIL', '$DJANGO_ADMIN_PASSWORD') + +# Check if the user already exists +if not User.objects.filter(username='$DJANGO_ADMIN_USERNAME').exists(): + # Create the superuser + superuser = User.objects.create_superuser( + username='$DJANGO_ADMIN_USERNAME', + email='$DJANGO_ADMIN_EMAIL', + password='$DJANGO_ADMIN_PASSWORD' + ) print("Superuser created successfully.") + + # Create the EmailAddress object for AllAuth + EmailAddress.objects.create( + user=superuser, + email='$DJANGO_ADMIN_EMAIL', + verified=True, + primary=True + ) + print("EmailAddress object created successfully for AllAuth.") else: print("Superuser already exists.") EOF fi + +# Sync the countries and world travel regions # Sync the countries and world travel regions python manage.py download-countries +if [ $? -eq 137 ]; then + >&2 echo "WARNING: The download-countries command was interrupted. This is likely due to lack of memory allocated to the container or the host. Please try again with more memory." + exit 1 +fi cat /code/adventurelog.txt diff --git a/backend/nginx.conf b/backend/nginx.conf index fafff04..b4bad7d 100644 --- a/backend/nginx.conf +++ b/backend/nginx.conf @@ -38,4 +38,4 @@ http { alias /code/media/; # Serve media files directly } } -} +} \ No newline at end of file diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index be1793b..5c39301 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -2,7 +2,7 @@ import os from django.contrib import admin from django.utils.html import mark_safe from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category -from worldtravel.models import Country, Region, VisitedRegion +from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from allauth.account.decorators import secure_admin_login admin.autodiscover() @@ -51,6 +51,16 @@ class RegionAdmin(admin.ModelAdmin): number_of_visits.short_description = 'Number of Visits' +class CityAdmin(admin.ModelAdmin): + list_display = ('name', 'region', 'country') + list_filter = ('region', 'region__country') + search_fields = ('name', 'region__name', 'region__country__name') + + def country(self, obj): + return obj.region.country.name + + country.short_description = 'Country' + from django.contrib import admin from django.contrib.auth.admin import UserAdmin from users.models import CustomUser @@ -127,6 +137,8 @@ admin.site.register(Checklist) admin.site.register(ChecklistItem) admin.site.register(AdventureImage, AdventureImageAdmin) admin.site.register(Category, CategoryAdmin) +admin.site.register(City, CityAdmin) +admin.site.register(VisitedCity) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/middleware.py b/backend/server/adventures/middleware.py index 3cd9713..550e581 100644 --- a/backend/server/adventures/middleware.py +++ b/backend/server/adventures/middleware.py @@ -20,4 +20,23 @@ class PrintCookiesMiddleware: def __call__(self, request): print(request.COOKIES) response = self.get_response(request) - return response \ No newline at end of file + return response + +# middlewares.py + +import os +from django.http import HttpRequest + +class OverrideHostMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request: HttpRequest): + # Override the host with the PUBLIC_URL environment variable + public_url = os.getenv('PUBLIC_URL', None) + if public_url: + # Split the public URL to extract the host and port (if available) + host = public_url.split("//")[-1].split("/")[0] + request.META['HTTP_HOST'] = host # Override the HTTP_HOST header + response = self.get_response(request) + return response diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 3ee3e79..1ce81f5 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -8,7 +8,7 @@ from django.db.models.functions import Lower from rest_framework.response import Response from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, Category from django.core.exceptions import PermissionDenied -from worldtravel.models import VisitedRegion, Region, Country +from worldtravel.models import VisitedCity, VisitedRegion, Region, Country, City from .serializers import AdventureImageSerializer, AdventureSerializer, CategorySerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer from rest_framework.permissions import IsAuthenticated from django.db.models import Q @@ -20,6 +20,7 @@ from django.contrib.auth import get_user_model from icalendar import Calendar, Event, vText, vCalAddress from django.http import HttpResponse from datetime import datetime +from django.db.models import Min User = get_user_model() @@ -44,17 +45,25 @@ class AdventureViewSet(viewsets.ModelViewSet): order_direction = self.request.query_params.get('order_direction', 'asc') include_collections = self.request.query_params.get('include_collections', 'true') - valid_order_by = ['name', 'type', 'start_date', 'rating', 'updated_at'] + valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at'] if order_by not in valid_order_by: order_by = 'name' if order_direction not in ['asc', 'desc']: order_direction = 'asc' + if order_by == 'date': + # order by the earliest visit object associated with the adventure + queryset = queryset.annotate(earliest_visit=Min('visits__start_date')) + queryset = queryset.filter(earliest_visit__isnull=False) + ordering = 'earliest_visit' # Apply case-insensitive sorting for the 'name' field - if order_by == 'name': + elif order_by == 'name': queryset = queryset.annotate(lower_name=Lower('name')) ordering = 'lower_name' + elif order_by == 'rating': + queryset = queryset.filter(rating__isnull=False) + ordering = 'rating' else: ordering = order_by @@ -1150,41 +1159,48 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): Returns a dictionary containing the region name, country name, and ISO code if found. """ iso_code = None - town = None - city = None - county = None + town_city_or_county = None display_name = None country_code = None + city = None + + # town = None + # city = None + # county = None + if 'address' in data.keys(): keys = data['address'].keys() for key in keys: if key.find("ISO") != -1: iso_code = data['address'][key] if 'town' in keys: - town = data['address']['town'] + town_city_or_county = data['address']['town'] if 'county' in keys: - county = data['address']['county'] + town_city_or_county = data['address']['county'] if 'city' in keys: - city = data['address']['city'] + town_city_or_county = data['address']['city'] if not iso_code: return {"error": "No region found"} + region = Region.objects.filter(id=iso_code).first() visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first() - is_visited = False + + region_visited = False + city_visited = False country_code = iso_code[:2] if region: - if city: - display_name = f"{city}, {region.name}, {country_code}" - elif town: - display_name = f"{town}, {region.name}, {country_code}" - elif county: - display_name = f"{county}, {region.name}, {country_code}" + if town_city_or_county: + display_name = f"{town_city_or_county}, {region.name}, {country_code}" + city = City.objects.filter(name__contains=town_city_or_county, region=region).first() + visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first() if visited_region: - is_visited = True + region_visited = True + if visited_city: + city_visited = True if region: - return {"id": iso_code, "region": region.name, "country": region.country.name, "is_visited": is_visited, "display_name": display_name} + return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited} return {"error": "No region found"} @action(detail=False, methods=['get']) diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 5acecb7..06bd33b 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -42,6 +42,7 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.sites', + # "allauth_ui", 'rest_framework', 'rest_framework.authtoken', 'allauth', @@ -49,8 +50,8 @@ INSTALLED_APPS = ( 'allauth.mfa', 'allauth.headless', 'allauth.socialaccount', - # "widget_tweaks", - # "slippers", + 'allauth.socialaccount.providers.github', + 'allauth.socialaccount.providers.openid_connect', 'drf_yasg', 'corsheaders', 'adventures', @@ -58,6 +59,9 @@ INSTALLED_APPS = ( 'users', 'integrations', 'django.contrib.gis', + # 'widget_tweaks', + # 'slippers', + ) MIDDLEWARE = ( @@ -65,6 +69,7 @@ MIDDLEWARE = ( 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', + 'adventures.middleware.OverrideHostMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', @@ -75,6 +80,8 @@ MIDDLEWARE = ( # disable verifications for new users ACCOUNT_EMAIL_VERIFICATION = 'none' +ALLAUTH_UI_THEME = "night" + CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', @@ -120,7 +127,7 @@ USE_L10N = True USE_TZ = True - +SESSION_COOKIE_SAMESITE = None # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.7/howto/static-files/ @@ -143,6 +150,7 @@ STORAGES = { } } +SILENCED_SYSTEM_CHECKS = ["slippers.E001"] TEMPLATES = [ { @@ -175,6 +183,11 @@ SESSION_SAVE_EVERY_REQUEST = True FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000') +# Set login redirect URL to the frontend +LOGIN_REDIRECT_URL = FRONTEND_URL + +SOCIALACCOUNT_LOGIN_ON_GET = True + HEADLESS_FRONTEND_URLS = { "account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}", "account_reset_password": f"{FRONTEND_URL}/user/reset-password", @@ -263,4 +276,4 @@ LOGGING = { }, } # https://github.com/dr5hn/countries-states-cities-database/tags -COUNTRY_REGION_JSON_VERSION = 'v2.4' \ No newline at end of file +COUNTRY_REGION_JSON_VERSION = 'v2.5' \ No newline at end of file diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index ab1e084..571946e 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -3,8 +3,8 @@ from django.contrib import admin from django.views.generic import RedirectView, TemplateView from django.conf import settings from django.conf.urls.static import static -from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView -from .views import get_csrf_token +from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView +from .views import get_csrf_token, get_public_url from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -27,7 +27,10 @@ urlpatterns = [ path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'), + path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), + path('csrf/', get_csrf_token, name='get_csrf_token'), + path('public-url/', get_public_url, name='get_public_url'), path('', TemplateView.as_view(template_name='home.html')), diff --git a/backend/server/main/views.py b/backend/server/main/views.py index 7a7507d..f21082b 100644 --- a/backend/server/main/views.py +++ b/backend/server/main/views.py @@ -1,6 +1,10 @@ from django.http import JsonResponse from django.middleware.csrf import get_token +from os import getenv def get_csrf_token(request): csrf_token = get_token(request) return JsonResponse({'csrfToken': csrf_token}) + +def get_public_url(request): + return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) \ No newline at end of file diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index bae189f..4e6b0aa 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -13,8 +13,10 @@ django-geojson setuptools gunicorn==23.0.0 qrcode==8.0 -# slippers==0.6.2 -# django-allauth-ui==1.5.1 -# django-widget-tweaks==1.5.0 +slippers==0.6.2 +django-allauth-ui==1.5.1 +django-widget-tweaks==1.5.0 django-ical==1.9.2 -icalendar==6.1.0 \ No newline at end of file +icalendar==6.1.0 +ijson==3.3.0 +tqdm==4.67.1 \ No newline at end of file diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index b85608c..0e0828f 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -36,7 +36,7 @@ import os class UserDetailsSerializer(serializers.ModelSerializer): """ - User model w/o password + User model without exposing the password. """ @staticmethod @@ -49,8 +49,8 @@ class UserDetailsSerializer(serializers.ModelSerializer): return username class Meta: + model = CustomUser extra_fields = ['profile_pic', 'uuid', 'public_profile'] - profile_pic = serializers.ImageField(required=False) if hasattr(UserModel, 'USERNAME_FIELD'): extra_fields.append(UserModel.USERNAME_FIELD) @@ -64,19 +64,14 @@ class UserDetailsSerializer(serializers.ModelSerializer): extra_fields.append('date_joined') if hasattr(UserModel, 'is_staff'): extra_fields.append('is_staff') - if hasattr(UserModel, 'public_profile'): - extra_fields.append('public_profile') - class Meta: - model = CustomUser - fields = ('profile_pic', 'uuid', 'public_profile', 'email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') - - model = UserModel - fields = ('pk', *extra_fields) + fields = ['pk', *extra_fields] read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') def handle_public_profile_change(self, instance, validated_data): - """Remove user from `shared_with` if public profile is set to False.""" + """ + Remove user from `shared_with` if public profile is set to False. + """ if 'public_profile' in validated_data and not validated_data['public_profile']: for collection in Collection.objects.filter(shared_with=instance): collection.shared_with.remove(instance) @@ -91,20 +86,37 @@ class UserDetailsSerializer(serializers.ModelSerializer): class CustomUserDetailsSerializer(UserDetailsSerializer): + """ + Custom serializer to add additional fields and logic for the user details. + """ + has_password = serializers.SerializerMethodField() class Meta(UserDetailsSerializer.Meta): model = CustomUser - fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile') - read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid',) + fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password'] + read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password') + + @staticmethod + def get_has_password(instance): + """ + Computes whether the user has a usable password set. + """ + return instance.has_usable_password() def to_representation(self, instance): + """ + Customizes the serialized output to modify `profile_pic` URL and add computed fields. + """ representation = super().to_representation(instance) + + # Construct profile picture URL if it exists if instance.profile_pic: public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') - #print(public_url) - # remove any ' from the url - public_url = public_url.replace("'", "") + public_url = public_url.replace("'", "") # Sanitize URL representation['profile_pic'] = f"{public_url}/media/{instance.profile_pic.name}" - del representation['pk'] # remove the pk field from the response + + # Remove `pk` field from the response + representation.pop('pk', None) + return representation diff --git a/backend/server/users/views.py b/backend/server/users/views.py index 109d04b..b03760e 100644 --- a/backend/server/users/views.py +++ b/backend/server/users/views.py @@ -1,3 +1,4 @@ +from os import getenv from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -9,6 +10,7 @@ from django.conf import settings from django.shortcuts import get_object_or_404 from django.contrib.auth import get_user_model from .serializers import CustomUserDetailsSerializer as PublicUserSerializer +from allauth.socialaccount.models import SocialApp User = get_user_model() @@ -120,4 +122,31 @@ class UpdateUserMetadataView(APIView): if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class EnabledSocialProvidersView(APIView): + """ + Get enabled social providers for social authentication. This is used to determine which buttons to show on the frontend. Also returns a URL for each to start the authentication flow. + """ + + @swagger_auto_schema( + responses={ + 200: openapi.Response('Enabled social providers'), + 400: 'Bad Request' + }, + operation_description="Get enabled social providers." + ) + def get(self, request): + social_providers = SocialApp.objects.filter(sites=settings.SITE_ID) + providers = [] + for provider in social_providers: + if provider.provider == 'openid_connect': + new_provider = f'oidc/{provider.client_id}' + else: + new_provider = provider.provider + providers.append({ + 'provider': provider.provider, + 'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/", + 'name': provider.name + }) + return Response(providers, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/server/worldtravel/management/commands/download-countries.py b/backend/server/worldtravel/management/commands/download-countries.py index 06382cb..f5c5702 100644 --- a/backend/server/worldtravel/management/commands/download-countries.py +++ b/backend/server/worldtravel/management/commands/download-countries.py @@ -1,9 +1,10 @@ import os from django.core.management.base import BaseCommand import requests -from worldtravel.models import Country, Region +from worldtravel.models import Country, Region, City from django.db import transaction -import json +from tqdm import tqdm +import ijson from django.conf import settings @@ -37,33 +38,54 @@ def saveCountryFlag(country_code): class Command(BaseCommand): help = 'Imports the world travel data' - def handle(self, *args, **options): - countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions-{COUNTRY_REGION_JSON_VERSION}.json') - if not os.path.exists(countries_json_path): - res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/countries%2Bstates.json') + def add_arguments(self, parser): + parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file') + + def handle(self, **options): + force = options['force'] + batch_size = 100 + countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions+states-{COUNTRY_REGION_JSON_VERSION}.json') + if not os.path.exists(countries_json_path) or force: + res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/json/countries%2Bstates%2Bcities.json') if res.status_code == 200: with open(countries_json_path, 'w') as f: f.write(res.text) + self.stdout.write(self.style.SUCCESS('countries+regions+states.json downloaded successfully')) else: - self.stdout.write(self.style.ERROR('Error downloading countries+regions.json')) + self.stdout.write(self.style.ERROR('Error downloading countries+regions+states.json')) return + elif not os.path.isfile(countries_json_path): + self.stdout.write(self.style.ERROR('countries+regions+states.json is not a file')) + return + elif os.path.getsize(countries_json_path) == 0: + self.stdout.write(self.style.ERROR('countries+regions+states.json is empty')) + elif Country.objects.count() == 0 or Region.objects.count() == 0 or City.objects.count() == 0: + self.stdout.write(self.style.WARNING('Some region data is missing. Re-importing all data.')) + else: + self.stdout.write(self.style.SUCCESS('Latest country, region, and state data already downloaded.')) + return with open(countries_json_path, 'r') as f: - data = json.load(f) + f = open(countries_json_path, 'rb') + parser = ijson.items(f, 'item') with transaction.atomic(): existing_countries = {country.country_code: country for country in Country.objects.all()} existing_regions = {region.id: region for region in Region.objects.all()} + existing_cities = {city.id: city for city in City.objects.all()} countries_to_create = [] regions_to_create = [] countries_to_update = [] regions_to_update = [] + cities_to_create = [] + cities_to_update = [] processed_country_codes = set() processed_region_ids = set() + processed_city_ids = set() - for country in data: + for country in parser: country_code = country['iso2'] country_name = country['name'] country_subregion = country['subregion'] @@ -93,7 +115,6 @@ class Command(BaseCommand): countries_to_create.append(country_obj) saveCountryFlag(country_code) - self.stdout.write(self.style.SUCCESS(f'Country {country_name} prepared')) if country['states']: for state in country['states']: @@ -102,6 +123,11 @@ class Command(BaseCommand): latitude = round(float(state['latitude']), 6) if state['latitude'] else None longitude = round(float(state['longitude']), 6) if state['longitude'] else None + # Check for duplicate regions + if state_id in processed_region_ids: + # self.stdout.write(self.style.ERROR(f'State {state_id} already processed')) + continue + processed_region_ids.add(state_id) if state_id in existing_regions: @@ -120,7 +146,40 @@ class Command(BaseCommand): latitude=latitude ) regions_to_create.append(region_obj) - self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared')) + # self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared')) + + if 'cities' in state and len(state['cities']) > 0: + for city in state['cities']: + city_id = f"{state_id}-{city['id']}" + city_name = city['name'] + latitude = round(float(city['latitude']), 6) if city['latitude'] else None + longitude = round(float(city['longitude']), 6) if city['longitude'] else None + + # Check for duplicate cities + if city_id in processed_city_ids: + # self.stdout.write(self.style.ERROR(f'City {city_id} already processed')) + continue + + processed_city_ids.add(city_id) + + if city_id in existing_cities: + city_obj = existing_cities[city_id] + city_obj.name = city_name + city_obj.region = region_obj + city_obj.longitude = longitude + city_obj.latitude = latitude + cities_to_update.append(city_obj) + else: + city_obj = City( + id=city_id, + name=city_name, + region=region_obj, + longitude=longitude, + latitude=latitude + ) + cities_to_create.append(city_obj) + # self.stdout.write(self.style.SUCCESS(f'City {city_id} prepared')) + else: state_id = f"{country_code}-00" processed_region_ids.add(state_id) @@ -136,18 +195,35 @@ class Command(BaseCommand): country=country_obj ) regions_to_create.append(region_obj) - self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}')) + # self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}')) + for i in tqdm(range(0, len(countries_to_create), batch_size), desc="Processing countries"): + batch = countries_to_create[i:i + batch_size] + Country.objects.bulk_create(batch) - # Bulk create new countries and regions - Country.objects.bulk_create(countries_to_create) - Region.objects.bulk_create(regions_to_create) + for i in tqdm(range(0, len(regions_to_create), batch_size), desc="Processing regions"): + batch = regions_to_create[i:i + batch_size] + Region.objects.bulk_create(batch) - # Bulk update existing countries and regions - Country.objects.bulk_update(countries_to_update, ['name', 'subregion', 'capital']) - Region.objects.bulk_update(regions_to_update, ['name', 'country', 'longitude', 'latitude']) + for i in tqdm(range(0, len(cities_to_create), batch_size), desc="Processing cities"): + batch = cities_to_create[i:i + batch_size] + City.objects.bulk_create(batch) - # Delete countries and regions that are no longer in the data + # Process updates in batches + for i in range(0, len(countries_to_update), batch_size): + batch = countries_to_update[i:i + batch_size] + for i in tqdm(range(0, len(countries_to_update), batch_size), desc="Updating countries"): + batch = countries_to_update[i:i + batch_size] + Country.objects.bulk_update(batch, ['name', 'subregion', 'capital', 'longitude', 'latitude']) + + for i in tqdm(range(0, len(regions_to_update), batch_size), desc="Updating regions"): + batch = regions_to_update[i:i + batch_size] + Region.objects.bulk_update(batch, ['name', 'country', 'longitude', 'latitude']) + + for i in tqdm(range(0, len(cities_to_update), batch_size), desc="Updating cities"): + batch = cities_to_update[i:i + batch_size] + City.objects.bulk_update(batch, ['name', 'region', 'longitude', 'latitude']) Country.objects.exclude(country_code__in=processed_country_codes).delete() Region.objects.exclude(id__in=processed_region_ids).delete() + City.objects.exclude(id__in=processed_city_ids).delete() self.stdout.write(self.style.SUCCESS('All data imported successfully')) \ No newline at end of file diff --git a/backend/server/worldtravel/migrations/0012_city.py b/backend/server/worldtravel/migrations/0012_city.py new file mode 100644 index 0000000..d14b088 --- /dev/null +++ b/backend/server/worldtravel/migrations/0012_city.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.8 on 2025-01-09 15:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0011_country_latitude_country_longitude'), + ] + + operations = [ + migrations.CreateModel( + name='City', + fields=[ + ('id', models.CharField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('region', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.region')), + ], + options={ + 'verbose_name_plural': 'Cities', + }, + ), + ] diff --git a/backend/server/worldtravel/migrations/0013_visitedcity.py b/backend/server/worldtravel/migrations/0013_visitedcity.py new file mode 100644 index 0000000..3b1e294 --- /dev/null +++ b/backend/server/worldtravel/migrations/0013_visitedcity.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.8 on 2025-01-09 17:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0012_city'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='VisitedCity', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.city')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py b/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py new file mode 100644 index 0000000..f2ea944 --- /dev/null +++ b/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.8 on 2025-01-09 18:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0013_visitedcity'), + ] + + operations = [ + migrations.AlterModelOptions( + name='visitedcity', + options={'verbose_name_plural': 'Visited Cities'}, + ), + ] diff --git a/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py b/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py new file mode 100644 index 0000000..5d7223b --- /dev/null +++ b/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.8 on 2025-01-13 17:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0014_alter_visitedcity_options'), + ] + + operations = [ + migrations.AddField( + model_name='city', + name='insert_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='country', + name='insert_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='region', + name='insert_id', + field=models.UUIDField(blank=True, null=True), + ), + ] diff --git a/backend/server/worldtravel/models.py b/backend/server/worldtravel/models.py index 2acc629..6c7ebb8 100644 --- a/backend/server/worldtravel/models.py +++ b/backend/server/worldtravel/models.py @@ -17,6 +17,7 @@ class Country(models.Model): capital = models.CharField(max_length=100, blank=True, null=True) longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + insert_id = models.UUIDField(unique=False, blank=True, null=True) class Meta: verbose_name = "Country" @@ -31,6 +32,21 @@ class Region(models.Model): country = models.ForeignKey(Country, on_delete=models.CASCADE) longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + insert_id = models.UUIDField(unique=False, blank=True, null=True) + + def __str__(self): + return self.name + +class City(models.Model): + id = models.CharField(primary_key=True) + name = models.CharField(max_length=100) + region = models.ForeignKey(Region, on_delete=models.CASCADE) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + insert_id = models.UUIDField(unique=False, blank=True, null=True) + + class Meta: + verbose_name_plural = "Cities" def __str__(self): return self.name @@ -47,4 +63,21 @@ class VisitedRegion(models.Model): def save(self, *args, **kwargs): if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists(): raise ValidationError("Region already visited by user.") - super().save(*args, **kwargs) \ No newline at end of file + super().save(*args, **kwargs) + +class VisitedCity(models.Model): + id = models.AutoField(primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + city = models.ForeignKey(City, on_delete=models.CASCADE) + + def __str__(self): + return f'{self.city.name} ({self.city.region.name}) visited by: {self.user_id.username}' + + def save(self, *args, **kwargs): + if VisitedCity.objects.filter(user_id=self.user_id, city=self.city).exists(): + raise ValidationError("City already visited by user.") + super().save(*args, **kwargs) + + class Meta: + verbose_name_plural = "Visited Cities" \ No newline at end of file diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index 70f569b..99c7379 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Country, Region, VisitedRegion +from .models import Country, Region, VisitedRegion, City, VisitedCity from rest_framework import serializers from main.utils import CustomModelSerializer @@ -33,10 +33,20 @@ class CountrySerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer): + num_cities = serializers.SerializerMethodField() class Meta: model = Region fields = '__all__' - read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude'] + read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities'] + + def get_num_cities(self, obj): + return City.objects.filter(region=obj).count() + +class CitySerializer(serializers.ModelSerializer): + class Meta: + model = City + fields = '__all__' + read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude'] class VisitedRegionSerializer(CustomModelSerializer): longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True) @@ -46,4 +56,14 @@ class VisitedRegionSerializer(CustomModelSerializer): class Meta: model = VisitedRegion fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name'] + read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name'] + +class VisitedCitySerializer(CustomModelSerializer): + longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True) + latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True) + name = serializers.CharField(source='city.name', read_only=True) + + class Meta: + model = VisitedCity + fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name'] read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name'] \ No newline at end of file diff --git a/backend/server/worldtravel/urls.py b/backend/server/worldtravel/urls.py index 46fe197..f28beda 100644 --- a/backend/server/worldtravel/urls.py +++ b/backend/server/worldtravel/urls.py @@ -2,15 +2,17 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country - +from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region router = DefaultRouter() router.register(r'countries', CountryViewSet, basename='countries') router.register(r'regions', RegionViewSet, basename='regions') router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion') +router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity') urlpatterns = [ path('', include(router.urls)), path('/regions/', regions_by_country, name='regions-by-country'), - path('/visits/', visits_by_country, name='visits-by-country') + path('/visits/', visits_by_country, name='visits-by-country'), + path('regions//cities/', cities_by_region, name='cities-by-region'), + path('regions//cities/visits/', visits_by_region, name='visits-by-region'), ] diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 5a92e39..c77309d 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -1,6 +1,6 @@ from django.shortcuts import render -from .models import Country, Region, VisitedRegion -from .serializers import CountrySerializer, RegionSerializer, VisitedRegionSerializer +from .models import Country, Region, VisitedRegion, City, VisitedCity +from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from django.shortcuts import get_object_or_404 @@ -33,6 +33,23 @@ def visits_by_country(request, country_code): serializer = VisitedRegionSerializer(visits, many=True) return Response(serializer.data) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def cities_by_region(request, region_id): + region = get_object_or_404(Region, id=region_id) + cities = City.objects.filter(region=region).order_by('name') + serializer = CitySerializer(cities, many=True) + return Response(serializer.data) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def visits_by_region(request, region_id): + region = get_object_or_404(Region, id=region_id) + visits = VisitedCity.objects.filter(city__region=region, user_id=request.user.id) + + serializer = VisitedCitySerializer(visits, many=True) + return Response(serializer.data) + class CountryViewSet(viewsets.ReadOnlyModelViewSet): queryset = Country.objects.all().order_by('name') serializer_class = CountrySerializer @@ -93,4 +110,46 @@ class VisitedRegionViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def destroy(self, request, **kwargs): + # delete by region id + region = get_object_or_404(Region, id=kwargs['pk']) + visited_region = VisitedRegion.objects.filter(user_id=request.user.id, region=region) + if visited_region.exists(): + visited_region.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND) + +class VisitedCityViewSet(viewsets.ModelViewSet): + serializer_class = VisitedCitySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return VisitedCity.objects.filter(user_id=self.request.user.id) + + def perform_create(self, serializer): + serializer.save(user_id=self.request.user) + + def create(self, request, *args, **kwargs): + request.data['user_id'] = request.user + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + # if the region is not visited, visit it + region = serializer.validated_data['city'].region + if not VisitedRegion.objects.filter(user_id=request.user.id, region=region).exists(): + VisitedRegion.objects.create(user_id=request.user, region=region) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def destroy(self, request, **kwargs): + # delete by city id + city = get_object_or_404(City, id=kwargs['pk']) + visited_city = VisitedCity.objects.filter(user_id=request.user.id, city=city) + if visited_city.exists(): + visited_city.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts index f119b27..5638acf 100644 --- a/documentation/.vitepress/config.mts +++ b/documentation/.vitepress/config.mts @@ -92,6 +92,28 @@ export default defineConfig({ text: "Immich Integration", link: "/docs/configuration/immich_integration", }, + { + text: "Social Auth and OIDC", + link: "/docs/configuration/social_auth", + }, + { + text: "Authentication Providers", + collapsed: false, + items: [ + { + text: "Authentik", + link: "/docs/configuration/social_auth/authentik", + }, + { + text: "GitHub", + link: "/docs/configuration/social_auth/github", + }, + { + text: "Open ID Connect", + link: "/docs/configuration/social_auth/oidc", + }, + ], + }, { text: "Update App", link: "/docs/configuration/updating", diff --git a/documentation/docs/configuration/social_auth.md b/documentation/docs/configuration/social_auth.md new file mode 100644 index 0000000..9da70e1 --- /dev/null +++ b/documentation/docs/configuration/social_auth.md @@ -0,0 +1,15 @@ +# Social Authentication + +AdventureLog support autentication via 3rd party services and self-hosted identity providers. Once these services are enabled, users can log in to AdventureLog using their accounts from these services and link exising AdventureLog accounts to these services for easier access. + +The steps for each service varies so please refer to the specific service's documentation for more information. + +## Supported Services + +- [Authentik](social_auth/authentik.md) (self-hosted) +- [GitHub](social_auth/github.md) +- [Open ID Connect](social_auth/oidc.md) + +## Linking Existing Accounts + +If you already have an AdventureLog account and would like to link it to a 3rd party service, you can do so by logging in to AdventureLog and navigating to the `Account Settings` page. From there, scroll down to `Social and OIDC Authentication` and click the `Launch Account Connections` button. If identity providers have been enabled on your instance, you will see a list of available services to link to. diff --git a/documentation/docs/configuration/social_auth/authentik.md b/documentation/docs/configuration/social_auth/authentik.md new file mode 100644 index 0000000..6bc62d9 --- /dev/null +++ b/documentation/docs/configuration/social_auth/authentik.md @@ -0,0 +1,56 @@ +# Authentik OIDC Authentication + +Authentik Logo + +Authentik is a self-hosted identity provider that supports OpenID Connect and OAuth2. AdventureLog can be configured to use Authentik as an identity provider for social authentication. Learn more about Authentik at [goauthentik.io](https://goauthentik.io/). + +Once Authentik is configured by the administrator, users can log in to AdventureLog using their Authentik account and link existing AdventureLog accounts to Authentik for easier access. + +# Configuration + +To enable Authentik as an identity provider, the administrator must first configure Authentik to allow AdventureLog to authenticate users. + +### Authentik Configuration + +1. Log in to Authentik and navigate to the `Providers` page and create a new provider. +2. Select `OAuth2/OpenID Provider` as the provider type. +3. Name it `AdventureLog` or any other name you prefer. +4. Set the `Redirect URI` of type `Regex` to `^http:///accounts/oidc/.*$` where `` is the URL of your AdventureLog Server service. +5. Copy the `Client ID` and `Client Secret` generated by Authentik, you will need these to configure AdventureLog. +6. Create an application in Authentik and assign the provider to it, name the `slug` `adventurelog` or any other name you prefer. +7. If you want the logo, you can find it [here](https://adventurelog.app/adventurelog.png). + +### AdventureLog Configuration + +This configuration is done in the [Admin Panel](../../guides/admin_panel.md). You can either launch the pannel directly from the `Settings` page or navigate to `/admin` on your AdventureLog server. + +1. Login to AdventureLog as an administrator and navigate to the `Settings` page. +2. Scroll down to the `Administration Settings` and launch the admin panel. +3. In the admin panel, navigate to the `Social Accounts` section and click the add button next to `Social applications`. Fill in the following fields: + + - Provider: `OpenID Connect` + - Provider ID: Autnentik Client ID + - Name: `Authentik` + - Client ID: Authentik Client ID + - Secret Key: Authentik Client Secret + - Key: can be left blank + - Settings: (make sure http/https is set correctly) + + ```json + { + "server_url": "http:///application/o/[YOUR_SLUG]/" + } + ``` + + ::: warning + `localhost` is most likely not a valid `server_url` for Authentik in this instance because `localhost` is the server running AdventureLog, not Authentik. You should use the IP address of the server running Authentik or the domain name if you have one. + +- Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites. + +#### What it Should Look Like + +![Authentik Social Auth Configuration](/authentik_settings.png) + +4. Save the configuration. + +Ensure that the Authentik server is running and accessible by AdventureLog. Users should now be able to log in to AdventureLog using their Authentik account. diff --git a/documentation/docs/configuration/social_auth/github.md b/documentation/docs/configuration/social_auth/github.md new file mode 100644 index 0000000..2239dc7 --- /dev/null +++ b/documentation/docs/configuration/social_auth/github.md @@ -0,0 +1,44 @@ +# GitHub Social Authentication + +AdventureLog can be configured to use GitHub as an identity provider for social authentication. Users can then log in to AdventureLog using their GitHub account. + +# Configuration + +To enable GitHub as an identity provider, the administrator must first configure GitHub to allow AdventureLog to authenticate users. + +### GitHub Configuration + +1. Visit the GitHub OAuth Apps Settings page at [https://github.com/settings/developers](https://github.com/settings/developers). +2. Click on `New OAuth App`. +3. Fill in the following fields: + + - Application Name: `AdventureLog` or any other name you prefer. + - Homepage URL: `` where `` is the URL of your AdventureLog Frontend service. + - Application Description: `AdventureLog` or any other description you prefer. + - Authorization callback URL: `http:///accounts/github/login/callback/` where `` is the URL of your AdventureLog Backend service. + - If you want the logo, you can find it [here](https://adventurelog.app/adventurelog.png). + +### AdventureLog Configuration + +This configuration is done in the [Admin Panel](../../guides/admin_panel.md). You can either launch the pannel directly from the `Settings` page or navigate to `/admin` on your AdventureLog server. + +1. Login to AdventureLog as an administrator and navigate to the `Settings` page. +2. Scroll down to the `Administration Settings` and launch the admin panel. +3. In the admin panel, navigate to the `Social Accounts` section and click the add button next to `Social applications`. Fill in the following fields: + + - Provider: `GitHub` + - Provider ID: GitHub Client ID + - Name: `GitHub` + - Client ID: GitHub Client ID + - Secret Key: GitHub Client Secret + - Key: can be left blank + - Settings: can be left blank + - Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites. + +#### What it Should Look Like + +![Authentik Social Auth Configuration](/github_settings.png) + +4. Save the configuration. + +Users should now be able to log in to AdventureLog using their GitHub account, and link it to exisiting accounts. diff --git a/documentation/docs/configuration/social_auth/oidc.md b/documentation/docs/configuration/social_auth/oidc.md new file mode 100644 index 0000000..0b0384d --- /dev/null +++ b/documentation/docs/configuration/social_auth/oidc.md @@ -0,0 +1,7 @@ +# OIDC Social Authentication + +AdventureLog can be configured to use OpenID Connect (OIDC) as an identity provider for social authentication. Users can then log in to AdventureLog using their OIDC account. + +The configuration is basically the same as [Authentik](./authentik.md), but you replace the client and secret with the OIDC client and secret provided by your OIDC provider. The `server_url` should be the URL of your OIDC provider where you can find the OIDC configuration. + +Each provider has a different configuration, so you will need to check the documentation of your OIDC provider to find the correct configuration. diff --git a/documentation/docs/configuration/updating.md b/documentation/docs/configuration/updating.md index c4eb3d3..aff9dd7 100644 --- a/documentation/docs/configuration/updating.md +++ b/documentation/docs/configuration/updating.md @@ -20,5 +20,5 @@ docker exec -it bash Once you are in the container run the following command to resync the region data. ```bash -python manage.py download-countries +python manage.py download-countries --force ``` diff --git a/documentation/docs/install/docker.md b/documentation/docs/install/docker.md index b46201d..c53d980 100644 --- a/documentation/docs/install/docker.md +++ b/documentation/docs/install/docker.md @@ -11,6 +11,12 @@ Docker is the preferred way to run AdventureLog on your local machine. It is a l Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine: +::: tip + +If running on an ARM based machine, you will need to use a different PostGIS Image. It is recommended to use the `tobi312/rpi-postgresql-postgis:15-3.3-alpine-arm` image or a custom version found [here](https://hub.docker.com/r/tobi312/rpi-postgresql-postgis/tags). The AdventureLog containers are ARM compatible. + +::: + ```bash wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml ``` diff --git a/documentation/public/authentik_settings.png b/documentation/public/authentik_settings.png new file mode 100644 index 0000000..d48e2c3 Binary files /dev/null and b/documentation/public/authentik_settings.png differ diff --git a/documentation/public/github_settings.png b/documentation/public/github_settings.png new file mode 100644 index 0000000..5d54369 Binary files /dev/null and b/documentation/public/github_settings.png differ diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 166ff33..56c5f94 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -15,6 +15,7 @@ declare global { profile_pic: string | null; uuid: string; public_profile: boolean; + has_password: boolean; } | null; locale: string; } diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 91e1b60..12cd017 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -3,6 +3,7 @@ import { sequence } from '@sveltejs/kit/hooks'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; export const authHook: Handle = async ({ event, resolve }) => { + event.cookies.delete('csrftoken', { path: '/' }); try { let sessionid = event.cookies.get('sessionid'); diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index eda9df6..322e67f 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -228,6 +228,37 @@ } } + async function handleMultipleFiles(event: Event) { + const files = (event.target as HTMLInputElement).files; + if (files) { + for (const file of files) { + await uploadImage(file); + } + } + } + + async function uploadImage(file: File) { + let formData = new FormData(); + formData.append('image', file); + formData.append('adventure', adventure.id); + + let res = await fetch(`/adventures?/image`, { + method: 'POST', + body: formData + }); + if (res.ok) { + let newData = deserialize(await res.text()) as { data: { id: string; image: string } }; + console.log(newData); + let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false }; + console.log(newImage); + images = [...images, newImage]; + adventure.images = images; + addToast('success', $t('adventures.image_upload_success')); + } else { + addToast('error', $t('adventures.image_upload_error')); + } + } + async function fetchImage() { try { let res = await fetch(url); @@ -241,39 +272,10 @@ formData.append('image', file); formData.append('adventure', adventure.id); - let res2 = await fetch(`/adventures?/image`, { - method: 'POST', - body: formData - }); - let data2 = await res2.json(); - - if (data2.type === 'success') { - console.log('Response Data:', data2); - - // Deserialize the nested data - let rawData = JSON.parse(data2.data); // Parse the data field - console.log('Deserialized Data:', rawData); - - // Assuming the first object in the array is the new image - let newImage = { - id: rawData[1], - image: rawData[2], // This is the URL for the image - is_primary: false - }; - console.log('New Image:', newImage); - - // Update images and adventure - images = [...images, newImage]; - adventure.images = images; - - addToast('success', $t('adventures.image_upload_success')); - url = ''; - } else { - addToast('error', $t('adventures.image_upload_error')); - } - } catch (error) { - console.error('Error in fetchImage:', error); - addToast('error', $t('adventures.image_upload_error')); + await uploadImage(file); + url = ''; + } catch (e) { + imageError = $t('adventures.image_fetch_failed'); } } @@ -365,15 +367,31 @@ async function markVisited() { console.log(reverseGeocodePlace); if (reverseGeocodePlace) { - let res = await fetch(`/worldtravel?/markVisited`, { - method: 'POST', - body: JSON.stringify({ regionId: reverseGeocodePlace.id }) - }); - if (res.ok) { - reverseGeocodePlace.is_visited = true; - addToast('success', `Visit to ${reverseGeocodePlace.region} marked`); - } else { - addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`); + if (!reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) { + let region_res = await fetch(`/api/visitedregion`, { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify({ region: reverseGeocodePlace.region_id }) + }); + if (region_res.ok) { + reverseGeocodePlace.region_visited = true; + addToast('success', `Visit to ${reverseGeocodePlace.region} marked`); + } else { + addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`); + } + } + if (!reverseGeocodePlace.city_visited && reverseGeocodePlace.city_id != null) { + let city_res = await fetch(`/api/visitedcity`, { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify({ city: reverseGeocodePlace.city_id }) + }); + if (city_res.ok) { + reverseGeocodePlace.city_visited = true; + addToast('success', `Visit to ${reverseGeocodePlace.city} marked`); + } else { + addToast('error', `Failed to mark visit to ${reverseGeocodePlace.city}`); + } } } } @@ -542,7 +560,10 @@ addToast('error', $t('adventures.adventure_update_error')); } } - if (adventure.is_visited && !reverseGeocodePlace?.is_visited) { + if ( + adventure.is_visited && + (!reverseGeocodePlace?.region_visited || !reverseGeocodePlace?.city_visited) + ) { markVisited(); } imageSearch = adventure.name; @@ -785,19 +806,33 @@ it would also work to just use on:click on the MapLibre component itself. --> {#if reverseGeocodePlace}
-

{reverseGeocodePlace.region}, {reverseGeocodePlace.country}

- {reverseGeocodePlace.is_visited + {reverseGeocodePlace.city + ? reverseGeocodePlace.city + ', ' + : ''}{reverseGeocodePlace.region}, + {reverseGeocodePlace.country} +

+

+ {reverseGeocodePlace.region}: + {reverseGeocodePlace.region_visited ? $t('adventures.visited') : $t('adventures.not_visited')}

+ {#if reverseGeocodePlace.city} +

+ {reverseGeocodePlace.city}: + {reverseGeocodePlace.city_visited + ? $t('adventures.visited') + : $t('adventures.not_visited')} +

+ {/if}
- {#if !reverseGeocodePlace.is_visited && !willBeMarkedVisited} + {#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)} {/if} - {#if !reverseGeocodePlace.is_visited && willBeMarkedVisited} + {#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)} diff --git a/frontend/src/lib/components/Avatar.svelte b/frontend/src/lib/components/Avatar.svelte index e9c7033..a94a8eb 100644 --- a/frontend/src/lib/components/Avatar.svelte +++ b/frontend/src/lib/components/Avatar.svelte @@ -30,8 +30,9 @@

- {$t('navbar.greeting')}, {user.first_name} - {user.last_name} + {$t('navbar.greeting')}, {user.first_name + ? `${user.first_name} ${user.last_name}` + : user.username}

  • diff --git a/frontend/src/lib/components/CategoryDropdown.svelte b/frontend/src/lib/components/CategoryDropdown.svelte index d509370..1de8638 100644 --- a/frontend/src/lib/components/CategoryDropdown.svelte +++ b/frontend/src/lib/components/CategoryDropdown.svelte @@ -86,7 +86,7 @@ bind:value={new_category.icon} /> + {/if} + {#if visited} + + {/if} + + + diff --git a/frontend/src/lib/components/RegionCard.svelte b/frontend/src/lib/components/RegionCard.svelte index c9754f0..5acd3a9 100644 --- a/frontend/src/lib/components/RegionCard.svelte +++ b/frontend/src/lib/components/RegionCard.svelte @@ -4,54 +4,47 @@ import type { Region, VisitedRegion } from '$lib/types'; import { createEventDispatcher } from 'svelte'; const dispatch = createEventDispatcher(); + import { t } from 'svelte-i18n'; export let region: Region; export let visited: boolean; - export let visit_id: number | undefined | null; + function goToCity() { + console.log(region); + goto(`/worldtravel/${region.id.split('-')[0]}/${region.id}`); + } async function markVisited() { - let res = await fetch(`/worldtravel?/markVisited`, { + let res = await fetch(`/api/visitedregion/`, { + headers: { 'Content-Type': 'application/json' }, method: 'POST', - body: JSON.stringify({ regionId: region.id }) + body: JSON.stringify({ region: region.id }) }); if (res.ok) { - // visited = true; - const result = await res.json(); - const data = JSON.parse(result.data); - if (data[1] !== undefined) { - console.log('New adventure created with id:', data[3]); - let visit_id = data[3]; - let region_id = data[5]; - let user_id = data[4]; - - let newVisit: VisitedRegion = { - id: visit_id, - region: region_id, - user_id: user_id, - longitude: 0, - latitude: 0, - name: '' - }; - addToast('success', `Visit to ${region.name} marked`); - dispatch('visit', newVisit); - } + visited = true; + let data = await res.json(); + addToast( + 'success', + `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.marked_visited')}` + ); + dispatch('visit', data); } else { - console.error('Failed to mark region as visited'); - addToast('error', `Failed to mark visit to ${region.name}`); + console.error($t('worldtravel.region_failed_visited')); + addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`); } } async function removeVisit() { - let res = await fetch(`/worldtravel?/removeVisited`, { - method: 'POST', - body: JSON.stringify({ visitId: visit_id }) + let res = await fetch(`/api/visitedregion/${region.id}`, { + headers: { 'Content-Type': 'application/json' }, + method: 'DELETE' }); if (res.ok) { visited = false; - addToast('info', `Visit to ${region.name} removed`); - dispatch('remove', null); + addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`); + dispatch('remove', region); } else { - console.error('Failed to remove visit'); + console.error($t('worldtravel.visit_remove_failed')); + addToast('error', `${$t('worldtravel.failed_to_remove_visit')} ${region.name}`); } } @@ -61,14 +54,28 @@ >

    {region.name}

    -

    {region.id}

    +
    +
    +

    {region.id}

    +
    +
    +

    {region.num_cities} {$t('worldtravel.cities')}

    +
    +
    {#if !visited} - + {/if} {#if visited} - + + {/if} + {#if region.num_cities > 0} + {/if}
    diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ea9e1fb..4677658 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -9,6 +9,7 @@ export type User = { profile_pic: string | null; uuid: string; public_profile: boolean; + has_password: boolean; }; export type Adventure = { @@ -56,16 +57,34 @@ export type Country = { }; export type Region = { - id: number; + id: string; name: string; - country: number; + country: string; latitude: number; longitude: number; + num_cities: number; +}; + +export type City = { + id: string; + name: string; + latitude: number | null; + longitude: number | null; + region: string; }; export type VisitedRegion = { id: number; - region: number; + region: string; + user_id: string; + longitude: number; + latitude: number; + name: string; +}; + +export type VisitedCity = { + id: number; + city: string; user_id: string; longitude: number; latitude: number; @@ -182,11 +201,14 @@ export type Background = { }; export type ReverseGeocode = { - id: string; + region_id: string; region: string; country: string; - is_visited: boolean; + region_visited: boolean; + city_visited: boolean; display_name: string; + city: string; + city_id: string; }; export type Category = { diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index dfb35cc..526a96e 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -284,7 +284,8 @@ "email_required": "E-Mail ist erforderlich", "both_passwords_required": "Beide Passwörter sind erforderlich", "new_password": "Neues Passwort", - "reset_failed": "Passwort konnte nicht zurückgesetzt werden" + "reset_failed": "Passwort konnte nicht zurückgesetzt werden", + "or_3rd_party": "Oder melden Sie sich bei einem Drittanbieter an" }, "users": { "no_users_found": "Keine Benutzer mit öffentlichen Profilen gefunden." @@ -298,7 +299,20 @@ "no_countries_found": "Keine Länder gefunden", "not_visited": "Nicht besucht", "num_countries": "Länder gefunden", - "partially_visited": "Teilweise besucht" + "partially_visited": "Teilweise besucht", + "all_visited": "Sie haben alle Regionen in besucht", + "cities": "Städte", + "failed_to_mark_visit": "Der Besuch konnte nicht markiert werden", + "failed_to_remove_visit": "Der Besuch von konnte nicht entfernt werden", + "marked_visited": "als besucht markiert", + "no_cities_found": "Keine Städte gefunden", + "region_failed_visited": "Die Region konnte nicht als besucht markiert werden", + "region_stats": "Regionsstatistiken", + "regions_in": "Regionen in", + "removed": "ENTFERNT", + "view_cities": "Städte anzeigen", + "visit_remove_failed": "Der Besuch konnte nicht entfernt werden", + "visit_to": "Besuch bei" }, "settings": { "account_settings": "Benutzerkontoeinstellungen", @@ -369,7 +383,17 @@ "csrf_failed": "CSRF-Token konnte nicht abgerufen werden", "duplicate_email": "Diese E-Mail-Adresse wird bereits verwendet.", "email_taken": "Diese E-Mail-Adresse wird bereits verwendet.", - "username_taken": "Dieser Benutzername wird bereits verwendet." + "username_taken": "Dieser Benutzername wird bereits verwendet.", + "administration_settings": "Verwaltungseinstellungen", + "documentation_link": "Dokumentationslink", + "launch_account_connections": "Kontoverbindungen starten", + "launch_administration_panel": "Starten Sie das Administrationspanel", + "no_verified_email_warning": "Sie müssen über eine verifizierte E-Mail-Adresse verfügen, um die Zwei-Faktor-Authentifizierung zu aktivieren.", + "social_auth_desc": "Aktivieren oder deaktivieren Sie soziale und OIDC-Authentifizierungsanbieter für Ihr Konto. \nMit diesen Verbindungen können Sie sich bei selbst gehosteten Authentifizierungsidentitätsanbietern wie Authentik oder Drittanbietern wie GitHub anmelden.", + "social_auth_desc_2": "Diese Einstellungen werden auf dem AdventureLog-Server verwaltet und müssen vom Administrator manuell aktiviert werden.", + "social_oidc_auth": "Soziale und OIDC-Authentifizierung", + "add_email": "E-Mail hinzufügen", + "password_too_short": "Das Passwort muss mindestens 6 Zeichen lang sein" }, "checklist": { "add_item": "Artikel hinzufügen", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a7e668e..4e98a1a 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -275,7 +275,20 @@ "completely_visited": "Completely Visited", "all_subregions": "All Subregions", "clear_search": "Clear Search", - "no_countries_found": "No countries found" + "no_countries_found": "No countries found", + "view_cities": "View Cities", + "no_cities_found": "No cities found", + "visit_to": "Visit to", + "region_failed_visited": "Failed to mark region as visited", + "failed_to_mark_visit": "Failed to mark visit to", + "visit_remove_failed": "Failed to remove visit", + "removed": "removed", + "failed_to_remove_visit": "Failed to remove visit to", + "marked_visited": "marked as visited", + "regions_in": "Regions in", + "region_stats": "Region Stats", + "all_visited": "You've visited all regions in", + "cities": "cities" }, "auth": { "username": "Username", @@ -295,7 +308,8 @@ "email_required": "Email is required", "new_password": "New Password (6+ characters)", "both_passwords_required": "Both passwords are required", - "reset_failed": "Failed to reset password" + "reset_failed": "Failed to reset password", + "or_3rd_party": "Or login with a third-party service" }, "users": { "no_users_found": "No users found with public profiles." @@ -306,6 +320,7 @@ "settings_page": "Settings Page", "account_settings": "User Account Settings", "update": "Update", + "no_verified_email_warning": "You must have a verified email address to enable two-factor authentication.", "password_change": "Change Password", "new_password": "New Password", "confirm_new_password": "Confirm New Password", @@ -369,7 +384,16 @@ "duplicate_email": "This email address is already in use.", "csrf_failed": "Failed to fetch CSRF token", "email_taken": "This email address is already in use.", - "username_taken": "This username is already in use." + "username_taken": "This username is already in use.", + "administration_settings": "Administration Settings", + "launch_administration_panel": "Launch Administration Panel", + "social_oidc_auth": "Social and OIDC Authentication", + "social_auth_desc": "Enable or disable social and OIDC authentication providers for your account. These connections allow you to sign in with self hosted authentication identity providers like Authentik or 3rd party providers like GitHub.", + "social_auth_desc_2": "These settings are managed in the AdventureLog server and must be manually enabled by the administrator.", + "documentation_link": "Documentation Link", + "launch_account_connections": "Launch Account Connections", + "password_too_short": "Password must be at least 6 characters", + "add_email": "Add Email" }, "collection": { "collection_created": "Collection created successfully!", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 0211650..f0a488b 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -275,7 +275,20 @@ "not_visited": "No visitado", "num_countries": "países encontrados", "partially_visited": "Parcialmente visitado", - "country_list": "Lista de países" + "country_list": "Lista de países", + "all_visited": "Has visitado todas las regiones en", + "cities": "ciudades", + "failed_to_mark_visit": "No se pudo marcar la visita a", + "failed_to_remove_visit": "No se pudo eliminar la visita a", + "marked_visited": "marcado como visitado", + "no_cities_found": "No se encontraron ciudades", + "region_failed_visited": "No se pudo marcar la región como visitada", + "region_stats": "Estadísticas de la región", + "regions_in": "Regiones en", + "removed": "remoto", + "view_cities": "Ver ciudades", + "visit_remove_failed": "No se pudo eliminar la visita", + "visit_to": "Visita a" }, "auth": { "forgot_password": "¿Has olvidado tu contraseña?", @@ -295,7 +308,8 @@ "email_required": "Se requiere correo electrónico", "both_passwords_required": "Se requieren ambas contraseñas", "new_password": "Nueva contraseña", - "reset_failed": "No se pudo restablecer la contraseña" + "reset_failed": "No se pudo restablecer la contraseña", + "or_3rd_party": "O inicie sesión con un servicio de terceros" }, "users": { "no_users_found": "No se encontraron usuarios con perfiles públicos." @@ -369,7 +383,17 @@ "csrf_failed": "No se pudo recuperar el token CSRF", "duplicate_email": "Esta dirección de correo electrónico ya está en uso.", "email_taken": "Esta dirección de correo electrónico ya está en uso.", - "username_taken": "Este nombre de usuario ya está en uso." + "username_taken": "Este nombre de usuario ya está en uso.", + "administration_settings": "Configuración de administración", + "documentation_link": "Enlace de documentación", + "launch_account_connections": "Iniciar conexiones de cuenta", + "launch_administration_panel": "Iniciar el panel de administración", + "no_verified_email_warning": "Debe tener una dirección de correo electrónico verificada para habilitar la autenticación de dos factores.", + "social_auth_desc": "Habilite o deshabilite los proveedores de autenticación social y OIDC para su cuenta. \nEstas conexiones le permiten iniciar sesión con proveedores de identidad de autenticación autohospedados como Authentik o proveedores externos como GitHub.", + "social_auth_desc_2": "Estas configuraciones se administran en el servidor AdventureLog y el administrador debe habilitarlas manualmente.", + "social_oidc_auth": "Autenticación social y OIDC", + "add_email": "Agregar correo electrónico", + "password_too_short": "La contraseña debe tener al menos 6 caracteres." }, "checklist": { "add_item": "Agregar artículo", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 4572619..d793779 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -284,7 +284,8 @@ "email_required": "L'e-mail est requis", "both_passwords_required": "Les deux mots de passe sont requis", "new_password": "Nouveau mot de passe", - "reset_failed": "Échec de la réinitialisation du mot de passe" + "reset_failed": "Échec de la réinitialisation du mot de passe", + "or_3rd_party": "Ou connectez-vous avec un service tiers" }, "users": { "no_users_found": "Aucun utilisateur trouvé avec des profils publics." @@ -298,7 +299,20 @@ "no_countries_found": "Aucun pays trouvé", "not_visited": "Non visité", "num_countries": "pays trouvés", - "partially_visited": "Partiellement visité" + "partially_visited": "Partiellement visité", + "all_visited": "Vous avez visité toutes les régions de", + "cities": "villes", + "failed_to_mark_visit": "Échec de la notation de la visite à", + "failed_to_remove_visit": "Échec de la suppression de la visite à", + "marked_visited": "marqué comme visité", + "no_cities_found": "Aucune ville trouvée", + "region_failed_visited": "Échec du marquage de la région comme visitée", + "region_stats": "Statistiques de la région", + "regions_in": "Régions dans", + "removed": "supprimé", + "view_cities": "Voir les villes", + "visit_remove_failed": "Échec de la suppression de la visite", + "visit_to": "Visite à" }, "settings": { "account_settings": "Paramètres du compte utilisateur", @@ -369,7 +383,17 @@ "csrf_failed": "Échec de la récupération du jeton CSRF", "duplicate_email": "Cette adresse e-mail est déjà utilisée.", "email_taken": "Cette adresse e-mail est déjà utilisée.", - "username_taken": "Ce nom d'utilisateur est déjà utilisé." + "username_taken": "Ce nom d'utilisateur est déjà utilisé.", + "administration_settings": "Paramètres d'administration", + "documentation_link": "Lien vers la documentation", + "launch_account_connections": "Lancer les connexions au compte", + "launch_administration_panel": "Lancer le panneau d'administration", + "no_verified_email_warning": "Vous devez disposer d'une adresse e-mail vérifiée pour activer l'authentification à deux facteurs.", + "social_auth_desc": "Activez ou désactivez les fournisseurs d'authentification sociale et OIDC pour votre compte. \nCes connexions vous permettent de vous connecter avec des fournisseurs d'identité d'authentification auto-hébergés comme Authentik ou des fournisseurs tiers comme GitHub.", + "social_auth_desc_2": "Ces paramètres sont gérés sur le serveur AdventureLog et doivent être activés manuellement par l'administrateur.", + "social_oidc_auth": "Authentification sociale et OIDC", + "add_email": "Ajouter un e-mail", + "password_too_short": "Le mot de passe doit contenir au moins 6 caractères" }, "checklist": { "add_item": "Ajouter un article", diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 9c06a22..85bb8ca 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -284,7 +284,8 @@ "email_required": "L'e-mail è obbligatoria", "both_passwords_required": "Sono necessarie entrambe le password", "new_password": "Nuova parola d'ordine", - "reset_failed": "Impossibile reimpostare la password" + "reset_failed": "Impossibile reimpostare la password", + "or_3rd_party": "Oppure accedi con un servizio di terze parti" }, "users": { "no_users_found": "Nessun utente trovato con profili pubblici." @@ -298,7 +299,20 @@ "no_countries_found": "Nessun paese trovato", "not_visited": "Non visitato", "num_countries": "paesi trovati", - "partially_visited": "Parzialmente visitato" + "partially_visited": "Parzialmente visitato", + "all_visited": "Hai visitato tutte le regioni in", + "cities": "città", + "failed_to_mark_visit": "Impossibile contrassegnare la visita a", + "failed_to_remove_visit": "Impossibile rimuovere la visita a", + "marked_visited": "contrassegnato come visitato", + "no_cities_found": "Nessuna città trovata", + "region_failed_visited": "Impossibile contrassegnare la regione come visitata", + "region_stats": "Statistiche della regione", + "regions_in": "Regioni dentro", + "removed": "RIMOSSO", + "view_cities": "Visualizza città", + "visit_remove_failed": "Impossibile rimuovere la visita", + "visit_to": "Visita a" }, "settings": { "account_settings": "Impostazioni dell'account utente", @@ -369,7 +383,17 @@ "csrf_failed": "Impossibile recuperare il token CSRF", "duplicate_email": "Questo indirizzo email è già in uso.", "email_taken": "Questo indirizzo email è già in uso.", - "username_taken": "Questo nome utente è già in uso." + "username_taken": "Questo nome utente è già in uso.", + "administration_settings": "Impostazioni di amministrazione", + "documentation_link": "Collegamento alla documentazione", + "launch_account_connections": "Avvia Connessioni account", + "launch_administration_panel": "Avvia il pannello di amministrazione", + "no_verified_email_warning": "È necessario disporre di un indirizzo e-mail verificato per abilitare l'autenticazione a due fattori.", + "social_auth_desc": "Abilita o disabilita i provider di autenticazione social e OIDC per il tuo account. \nQueste connessioni ti consentono di accedere con provider di identità di autenticazione self-hosted come Authentik o provider di terze parti come GitHub.", + "social_auth_desc_2": "Queste impostazioni sono gestite nel server AdventureLog e devono essere abilitate manualmente dall'amministratore.", + "social_oidc_auth": "Autenticazione sociale e OIDC", + "add_email": "Aggiungi e-mail", + "password_too_short": "La password deve contenere almeno 6 caratteri" }, "checklist": { "add_item": "Aggiungi articolo", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 91fb42b..30660fa 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -284,7 +284,8 @@ "email_required": "E-mail is vereist", "both_passwords_required": "Beide wachtwoorden zijn vereist", "new_password": "Nieuw wachtwoord", - "reset_failed": "Kan het wachtwoord niet opnieuw instellen" + "reset_failed": "Kan het wachtwoord niet opnieuw instellen", + "or_3rd_party": "Of log in met een service van derden" }, "users": { "no_users_found": "Er zijn geen gebruikers gevonden met openbare profielen." @@ -298,7 +299,20 @@ "no_countries_found": "Geen landen gevonden", "not_visited": "Niet bezocht", "num_countries": "landen gevonden", - "partially_visited": "Gedeeltelijk bezocht" + "partially_visited": "Gedeeltelijk bezocht", + "all_visited": "Je hebt alle regio's in bezocht", + "cities": "steden", + "failed_to_mark_visit": "Kan bezoek aan niet markeren", + "failed_to_remove_visit": "Kan bezoek aan niet verwijderen", + "marked_visited": "gemarkeerd als bezocht", + "no_cities_found": "Geen steden gevonden", + "region_failed_visited": "Kan de regio niet als bezocht markeren", + "region_stats": "Regiostatistieken", + "regions_in": "Regio's binnen", + "removed": "VERWIJDERD", + "view_cities": "Steden bekijken", + "visit_remove_failed": "Kan bezoek niet verwijderen", + "visit_to": "Bezoek aan" }, "settings": { "account_settings": "Gebruikersaccount instellingen", @@ -369,7 +383,17 @@ "csrf_failed": "Kan CSRF-token niet ophalen", "duplicate_email": "Dit e-mailadres is al in gebruik.", "email_taken": "Dit e-mailadres is al in gebruik.", - "username_taken": "Deze gebruikersnaam is al in gebruik." + "username_taken": "Deze gebruikersnaam is al in gebruik.", + "administration_settings": "Beheerinstellingen", + "documentation_link": "Documentatielink", + "launch_account_connections": "Start Accountverbindingen", + "launch_administration_panel": "Start het Beheerpaneel", + "no_verified_email_warning": "U moet een geverifieerd e-mailadres hebben om tweefactorauthenticatie in te schakelen.", + "social_auth_desc": "Schakel sociale en OIDC-authenticatieproviders in of uit voor uw account. \nMet deze verbindingen kunt u inloggen met zelfgehoste authenticatie-identiteitsproviders zoals Authentik of externe providers zoals GitHub.", + "social_auth_desc_2": "Deze instellingen worden beheerd op de AdventureLog-server en moeten handmatig worden ingeschakeld door de beheerder.", + "social_oidc_auth": "Sociale en OIDC-authenticatie", + "add_email": "E-mail toevoegen", + "password_too_short": "Wachtwoord moet minimaal 6 tekens lang zijn" }, "checklist": { "add_item": "Artikel toevoegen", diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index 641aa95..d70b73e 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -275,7 +275,20 @@ "completely_visited": "Całkowicie odwiedzone", "all_subregions": "Wszystkie podregiony", "clear_search": "Wyczyść wyszukiwanie", - "no_countries_found": "Nie znaleziono krajów" + "no_countries_found": "Nie znaleziono krajów", + "all_visited": "Odwiedziłeś wszystkie regiony w", + "cities": "miasta", + "failed_to_mark_visit": "Nie udało się oznaczyć wizyty w", + "failed_to_remove_visit": "Nie udało się usunąć wizyty w", + "marked_visited": "oznaczone jako odwiedzone", + "no_cities_found": "Nie znaleziono żadnych miast", + "region_failed_visited": "Nie udało się oznaczyć regionu jako odwiedzony", + "region_stats": "Statystyki regionu", + "regions_in": "Regiony w", + "removed": "REMOVED", + "view_cities": "Zobacz Miasta", + "visit_remove_failed": "Nie udało się usunąć wizyty", + "visit_to": "Wizyta w" }, "auth": { "username": "Nazwa użytkownika", @@ -295,7 +308,8 @@ "email_required": "Adres e-mail jest wymagany", "both_passwords_required": "Obydwa hasła są wymagane", "new_password": "Nowe hasło", - "reset_failed": "Nie udało się zresetować hasła" + "reset_failed": "Nie udało się zresetować hasła", + "or_3rd_party": "Lub zaloguj się za pomocą usługi strony trzeciej" }, "users": { "no_users_found": "Nie znaleziono użytkowników z publicznymi profilami." @@ -369,7 +383,17 @@ "csrf_failed": "Nie udało się pobrać tokena CSRF", "duplicate_email": "Ten adres e-mail jest już używany.", "email_taken": "Ten adres e-mail jest już używany.", - "username_taken": "Ta nazwa użytkownika jest już używana." + "username_taken": "Ta nazwa użytkownika jest już używana.", + "administration_settings": "Ustawienia administracyjne", + "documentation_link": "Link do dokumentacji", + "launch_account_connections": "Uruchom Połączenia kont", + "launch_administration_panel": "Uruchom Panel administracyjny", + "no_verified_email_warning": "Aby włączyć uwierzytelnianie dwuskładnikowe, musisz mieć zweryfikowany adres e-mail.", + "social_auth_desc": "Włącz lub wyłącz dostawców uwierzytelniania społecznościowego i OIDC dla swojego konta. \nPołączenia te umożliwiają logowanie się za pośrednictwem dostawców tożsamości uwierzytelniających, takich jak Authentik, lub dostawców zewnętrznych, takich jak GitHub.", + "social_auth_desc_2": "Ustawienia te są zarządzane na serwerze AdventureLog i muszą zostać włączone ręcznie przez administratora.", + "social_oidc_auth": "Uwierzytelnianie społecznościowe i OIDC", + "add_email": "Dodaj e-mail", + "password_too_short": "Hasło musi mieć co najmniej 6 znaków" }, "collection": { "collection_created": "Kolekcja została pomyślnie utworzona!", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 505fbb6..c9afb14 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -275,7 +275,20 @@ "no_countries_found": "Inga länder hittades", "not_visited": "Ej besökta", "num_countries": "länder hittades", - "partially_visited": "Delvis besökta" + "partially_visited": "Delvis besökta", + "all_visited": "Du har besökt alla regioner i", + "cities": "städer", + "failed_to_mark_visit": "Det gick inte att markera besök till", + "failed_to_remove_visit": "Det gick inte att ta bort besök på", + "marked_visited": "markerad som besökt", + "no_cities_found": "Inga städer hittades", + "region_failed_visited": "Det gick inte att markera regionen som besökt", + "region_stats": "Regionstatistik", + "regions_in": "Regioner i", + "removed": "tas bort", + "view_cities": "Visa städer", + "visit_remove_failed": "Det gick inte att ta bort besöket", + "visit_to": "Besök till" }, "auth": { "confirm_password": "Bekräfta lösenord", @@ -295,7 +308,8 @@ "email_required": "E-post krävs", "both_passwords_required": "Båda lösenorden krävs", "new_password": "Nytt lösenord", - "reset_failed": "Det gick inte att återställa lösenordet" + "reset_failed": "Det gick inte att återställa lösenordet", + "or_3rd_party": "Eller logga in med en tredjepartstjänst" }, "users": { "no_users_found": "Inga användare hittades med offentliga profiler." @@ -369,7 +383,17 @@ "csrf_failed": "Det gick inte att hämta CSRF-token", "duplicate_email": "Den här e-postadressen används redan.", "email_taken": "Den här e-postadressen används redan.", - "username_taken": "Detta användarnamn används redan." + "username_taken": "Detta användarnamn används redan.", + "administration_settings": "Administrationsinställningar", + "documentation_link": "Dokumentationslänk", + "launch_account_connections": "Starta kontoanslutningar", + "launch_administration_panel": "Starta administrationspanelen", + "no_verified_email_warning": "Du måste ha en verifierad e-postadress för att aktivera tvåfaktorsautentisering.", + "social_auth_desc": "Aktivera eller inaktivera sociala och OIDC-autentiseringsleverantörer för ditt konto. \nDessa anslutningar gör att du kan logga in med leverantörer av autentiseringsidentitetsidentitet som är värd för dig som Authentik eller tredjepartsleverantörer som GitHub.", + "social_auth_desc_2": "Dessa inställningar hanteras i AdventureLog-servern och måste aktiveras manuellt av administratören.", + "social_oidc_auth": "Social och OIDC-autentisering", + "add_email": "Lägg till e-post", + "password_too_short": "Lösenordet måste bestå av minst 6 tecken" }, "checklist": { "add_item": "Lägg till objekt", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 86674c5..99a5fad 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -284,7 +284,8 @@ "email_required": "电子邮件为必填项", "both_passwords_required": "两个密码都需要", "new_password": "新密码", - "reset_failed": "重置密码失败" + "reset_failed": "重置密码失败", + "or_3rd_party": "或者使用第三方服务登录" }, "worldtravel": { "all": "全部", @@ -295,7 +296,20 @@ "no_countries_found": "没有找到国家", "not_visited": "未访问过", "num_countries": "找到的国家", - "partially_visited": "部分访问" + "partially_visited": "部分访问", + "all_visited": "您已访问过所有地区", + "cities": "城市", + "failed_to_mark_visit": "无法标记访问", + "failed_to_remove_visit": "无法删除对的访问", + "marked_visited": "标记为已访问", + "no_cities_found": "没有找到城市", + "region_failed_visited": "无法将区域标记为已访问", + "region_stats": "地区统计", + "regions_in": "地区位于", + "removed": "已删除", + "view_cities": "查看城市", + "visit_remove_failed": "删除访问失败", + "visit_to": "参观" }, "users": { "no_users_found": "未找到具有公开个人资料的用户。" @@ -369,7 +383,17 @@ "csrf_failed": "获取 CSRF 令牌失败", "duplicate_email": "该电子邮件地址已被使用。", "email_taken": "该电子邮件地址已被使用。", - "username_taken": "该用户名已被使用。" + "username_taken": "该用户名已被使用。", + "administration_settings": "管理设置", + "documentation_link": "文档链接", + "launch_account_connections": "启动帐户连接", + "launch_administration_panel": "启动管理面板", + "no_verified_email_warning": "您必须拥有经过验证的电子邮件地址才能启用双因素身份验证。", + "social_auth_desc": "为您的帐户启用或禁用社交和 OIDC 身份验证提供商。\n这些连接允许您使用自托管身份验证身份提供商(如 Authentik)或第三方提供商(如 GitHub)登录。", + "social_auth_desc_2": "这些设置在 AdventureLog 服务器中进行管理,并且必须由管理员手动启用。", + "social_oidc_auth": "社交和 OIDC 身份验证", + "add_email": "添加电子邮件", + "password_too_short": "密码必须至少为 6 个字符" }, "checklist": { "add_item": "添加项目", diff --git a/frontend/src/routes/_allauth/[...path]/+server.ts b/frontend/src/routes/_allauth/[...path]/+server.ts index 681a3fa..9b09205 100644 --- a/frontend/src/routes/_allauth/[...path]/+server.ts +++ b/frontend/src/routes/_allauth/[...path]/+server.ts @@ -53,18 +53,25 @@ async function handleRequest( const headers = new Headers(request.headers); + // Delete existing csrf cookie by setting an expired date + cookies.delete('csrftoken', { path: '/' }); + + // Generate a new csrf token (using your existing fetchCSRFToken function) const csrfToken = await fetchCSRFToken(); if (!csrfToken) { return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); } + // Set the new csrf token in both headers and cookies + const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`; + try { const response = await fetch(targetUrl, { method: request.method, headers: { ...Object.fromEntries(headers), 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` + Cookie: cookieHeader }, body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index 33c2e2a..815d4a7 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -12,7 +12,7 @@ export async function GET(event) { /** @type {import('./$types').RequestHandler} */ export async function POST({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + const searchParam = url.search ? `${url.search}` : ''; return handleRequest(url, params, request, fetch, cookies, searchParam, true); } @@ -53,18 +53,25 @@ async function handleRequest( const headers = new Headers(request.headers); + // Delete existing csrf cookie by setting an expired date + cookies.delete('csrftoken', { path: '/' }); + + // Generate a new csrf token (using your existing fetchCSRFToken function) const csrfToken = await fetchCSRFToken(); if (!csrfToken) { return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); } + // Set the new csrf token in both headers and cookies + const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`; + try { const response = await fetch(targetUrl, { method: request.method, headers: { ...Object.fromEntries(headers), 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` + Cookie: cookieHeader }, body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, diff --git a/frontend/src/routes/auth/[...path]/+server.ts b/frontend/src/routes/auth/[...path]/+server.ts index 7e0c8b0..270b233 100644 --- a/frontend/src/routes/auth/[...path]/+server.ts +++ b/frontend/src/routes/auth/[...path]/+server.ts @@ -1,69 +1,84 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; +import { fetchCSRFToken } from '$lib/index.server'; import { json } from '@sveltejs/kit'; /** @type {import('./$types').RequestHandler} */ -export async function GET({ url, params, request, fetch, cookies }) { - // add the param format = json to the url or add additional if anothre param is already present - if (url.search) { - url.search = url.search + '&format=json'; - } else { - url.search = '?format=json'; - } - return handleRequest(url, params, request, fetch, cookies); +export async function GET(event) { + const { url, params, request, fetch, cookies } = event; + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam); } /** @type {import('./$types').RequestHandler} */ export async function POST({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function PATCH({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function PUT({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function DELETE({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } -// Implement other HTTP methods as needed (PUT, DELETE, etc.) - async function handleRequest( url: any, params: any, request: any, fetch: any, cookies: any, + searchParam: string, requreTrailingSlash: boolean | undefined = false ) { const path = params.path; - let targetUrl = `${endpoint}/auth/${path}${url.search}`; + let targetUrl = `${endpoint}/auth/${path}`; + // Ensure the path ends with a trailing slash if (requreTrailingSlash && !targetUrl.endsWith('/')) { targetUrl += '/'; } + // Append query parameters to the path correctly + targetUrl += searchParam; // This will add ?format=json or &format=json to the URL + const headers = new Headers(request.headers); - const authCookie = cookies.get('auth'); + // Delete existing csrf cookie by setting an expired date + cookies.delete('csrftoken', { path: '/' }); - if (authCookie) { - headers.set('Cookie', `${authCookie}`); + // Generate a new csrf token (using your existing fetchCSRFToken function) + const csrfToken = await fetchCSRFToken(); + if (!csrfToken) { + return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); } + // Set the new csrf token in both headers and cookies + const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`; + try { const response = await fetch(targetUrl, { method: request.method, - headers: headers, - body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined + headers: { + ...Object.fromEntries(headers), + 'X-CSRFToken': csrfToken, + Cookie: cookieHeader + }, + body: + request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, + credentials: 'include' // This line ensures cookies are sent with the request }); if (response.status === 204) { - // For 204 No Content, return a response with no body return new Response(null, { status: 204, headers: response.headers @@ -71,10 +86,13 @@ async function handleRequest( } const responseData = await response.text(); + // Create a new Headers object without the 'set-cookie' header + const cleanHeaders = new Headers(response.headers); + cleanHeaders.delete('set-cookie'); return new Response(responseData, { status: response.status, - headers: response.headers + headers: cleanHeaders }); } catch (error) { console.error('Error forwarding request:', error); diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 00139e5..43c32d2 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -17,7 +17,11 @@
    -

    {$t('dashboard.welcome_back')}, {user?.first_name}!

    +

    + {$t('dashboard.welcome_back')}, {user?.first_name + ? `${user.first_name} ${user.last_name}` + : user?.username}! +

    diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index adb0905..a57865c 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -4,6 +4,7 @@ import type { Actions, PageServerLoad, RouteParams } from './$types'; import { getRandomBackground, getRandomQuote } from '$lib'; import { fetchCSRFToken } from '$lib/index.server'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load: PageServerLoad = async (event) => { if (event.locals.user) { @@ -12,10 +13,17 @@ export const load: PageServerLoad = async (event) => { const quote = getRandomQuote(); const background = getRandomBackground(); + let socialProviderFetch = await event.fetch(`${serverEndpoint}/auth/social-providers/`); + if (!socialProviderFetch.ok) { + return fail(500, { message: 'settings.social_providers_error' }); + } + let socialProviders = await socialProviderFetch.json(); + return { props: { quote, - background + background, + socialProviders } }; } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 3bf6c98..d6ba6b7 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -9,14 +9,19 @@ let isImageInfoModalOpen: boolean = false; + let socialProviders = data.props?.socialProviders ?? []; + + import GitHub from '~icons/mdi/github'; + import OpenIdConnect from '~icons/mdi/openid'; + import { page } from '$app/stores'; import ImageInfoModal from '$lib/components/ImageInfoModal.svelte'; import type { Background } from '$lib/types.js'; - let quote: { quote: string; author: string } = data.props.quote; + let quote: { quote: string; author: string } = data.props?.quote ?? { quote: '', author: '' }; - let background: Background = data.props.background; + let background: Background = data.props?.background ?? { url: '' }; {#if isImageInfoModalOpen} @@ -62,6 +67,22 @@ {/if} + {#if socialProviders.length > 0} +
    {$t('auth.or_3rd_party')}
    + + {/if} +

    {$t('auth.signup')}

    diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts index ca29ee6..44bfe97 100644 --- a/frontend/src/routes/settings/+page.server.ts +++ b/frontend/src/routes/settings/+page.server.ts @@ -66,12 +66,22 @@ export const load: PageServerLoad = async (event) => { immichIntegration = await immichIntegrationsFetch.json(); } + let publicUrlFetch = await fetch(`${endpoint}/public-url/`); + let publicUrl = ''; + if (!publicUrlFetch.ok) { + return redirect(302, '/'); + } else { + let publicUrlJson = await publicUrlFetch.json(); + publicUrl = publicUrlJson.PUBLIC_URL; + } + return { props: { user, emails, authenticators, - immichIntegration + immichIntegration, + publicUrl } }; }; @@ -179,33 +189,57 @@ export const actions: Actions = { const password1 = formData.get('password1') as string | null | undefined; const password2 = formData.get('password2') as string | null | undefined; - const current_password = formData.get('current_password') as string | null | undefined; + let current_password = formData.get('current_password') as string | null | undefined; if (password1 !== password2) { return fail(400, { message: 'settings.password_does_not_match' }); } + if (!current_password) { - return fail(400, { message: 'settings.password_is_required' }); + current_password = null; + } + + if (password1 && password1?.length < 6) { + return fail(400, { message: 'settings.password_too_short' }); } let csrfToken = await fetchCSRFToken(); - let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { - method: 'POST', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'X-CSRFToken': csrfToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - current_password, - new_password: password1 - }) - }); - if (!res.ok) { - return fail(res.status, { message: 'settings.error_change_password' }); + if (current_password) { + let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { + method: 'POST', + headers: { + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + current_password, + new_password: password1 + }) + }); + if (!res.ok) { + return fail(res.status, { message: 'settings.error_change_password' }); + } + return { success: true }; + } else { + let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { + method: 'POST', + headers: { + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + new_password: password1 + }) + }); + if (!res.ok) { + console.log('Error:', await res.json()); + return fail(res.status, { message: 'settings.error_change_password' }); + } + return { success: true }; } - return { success: true }; }, changeEmail: async (event) => { if (!event.locals.user) { diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 6c2b97f..0fbe15c 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -9,6 +9,7 @@ import TotpModal from '$lib/components/TOTPModal.svelte'; import { appTitle, appVersion } from '$lib/config.js'; import ImmichLogo from '$lib/assets/immich.svg'; + import { goto } from '$app/navigation'; export let data; console.log(data); @@ -20,7 +21,7 @@ } let new_email: string = ''; - + let public_url: string = data.props.publicUrl; let immichIntegration = data.props.immichIntegration; let newImmichIntegration: ImmichIntegration = { @@ -307,17 +308,19 @@

    -
    - - -
    + {#if user.has_password} +
    + + +
    + {/if}
    + {#if $page.form?.message} +
    + {$t($page.form?.message)} +
    + {/if}
    - + - +
    @@ -413,9 +421,15 @@
    {#if !data.props.authenticators}

    {$t('settings.mfa_not_enabled')}

    - + {#if !emails.some((e) => e.verified)} +
    + {$t('settings.no_verified_email_warning')} +
    + {:else} + + {/if} {:else} + + {#if user.is_staff} +
    +

    + {$t('settings.administration_settings')} +

    + +
    + {/if} + + +
    +

    {$t('settings.social_oidc_auth')}

    +
    +

    + {$t('settings.social_auth_desc')} +

    + + {$t('settings.launch_account_connections')} +
    +
    +

    diff --git a/frontend/src/routes/worldtravel/+page.server.ts b/frontend/src/routes/worldtravel/+page.server.ts index ec696bf..b84ae39 100644 --- a/frontend/src/routes/worldtravel/+page.server.ts +++ b/frontend/src/routes/worldtravel/+page.server.ts @@ -30,80 +30,3 @@ export const load = (async (event) => { } } }) satisfies PageServerLoad; - -export const actions: Actions = { - markVisited: async (event) => { - const body = await event.request.json(); - - if (!body || !body.regionId) { - return { - status: 400 - }; - } - - let sessionId = event.cookies.get('sessionid'); - - if (!event.locals.user || !sessionId) { - return redirect(302, '/login'); - } - - let csrfToken = await fetchCSRFToken(); - - const res = await fetch(`${endpoint}/api/visitedregion/`, { - method: 'POST', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - }, - body: JSON.stringify({ region: body.regionId }) - }); - - if (!res.ok) { - console.error('Failed to mark country as visited'); - return { status: 500 }; - } else { - return { - status: 200, - data: await res.json() - }; - } - }, - removeVisited: async (event) => { - const body = await event.request.json(); - - if (!body || !body.visitId) { - return { - status: 400 - }; - } - - const visitId = body.visitId as number; - - let sessionId = event.cookies.get('sessionid'); - - if (!event.locals.user || !sessionId) { - return redirect(302, '/login'); - } - - let csrfToken = await fetchCSRFToken(); - - const res = await fetch(`${endpoint}/api/visitedregion/${visitId}/`, { - method: 'DELETE', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - } - }); - - if (res.status !== 204) { - console.error('Failed to remove country from visited'); - return { status: 500 }; - } else { - return { - status: 200 - }; - } - } -}; diff --git a/frontend/src/routes/worldtravel/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/+page.svelte index 53224e1..f0ca9fb 100644 --- a/frontend/src/routes/worldtravel/[id]/+page.svelte +++ b/frontend/src/routes/worldtravel/[id]/+page.svelte @@ -24,7 +24,7 @@ visitedRegions = visitedRegions.filter( (visitedRegion) => visitedRegion.region !== region.id ); - removeVisit(region, visitedRegion.id); + removeVisit(region); } else { markVisited(region); } @@ -32,48 +32,35 @@ } async function markVisited(region: Region) { - let res = await fetch(`/worldtravel?/markVisited`, { + let res = await fetch(`/api/visitedregion/`, { method: 'POST', - body: JSON.stringify({ regionId: region.id }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ region: region.id }) }); - if (res.ok) { - // visited = true; - const result = await res.json(); - const data = JSON.parse(result.data); - if (data[1] !== undefined) { - console.log('New adventure created with id:', data[3]); - let visit_id = data[3]; - let region_id = data[5]; - let user_id = data[4]; - - visitedRegions = [ - ...visitedRegions, - { - id: visit_id, - region: region_id, - user_id: user_id, - longitude: 0, - latitude: 0, - name: '' - } - ]; - - addToast('success', `Visit to ${region.name} marked`); - } - } else { + if (!res.ok) { console.error('Failed to mark region as visited'); - addToast('error', `Failed to mark visit to ${region.name}`); + addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`); + return; + } else { + visitedRegions = [...visitedRegions, await res.json()]; + addToast( + 'success', + `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.marked_visited')}` + ); } } - async function removeVisit(region: Region, visitId: number) { - let res = await fetch(`/worldtravel?/removeVisited`, { - method: 'POST', - body: JSON.stringify({ visitId: visitId }) + async function removeVisit(region: Region) { + let res = await fetch(`/api/visitedregion/${region.id}`, { + headers: { 'Content-Type': 'application/json' }, + method: 'DELETE' }); - if (res.ok) { - addToast('info', `Visit to ${region.name} removed`); + if (!res.ok) { + console.error($t('worldtravel.region_failed_visited')); + addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`); + return; } else { - console.error('Failed to remove visit'); + visitedRegions = visitedRegions.filter((visitedRegion) => visitedRegion.region !== region.id); + addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`); } } @@ -86,16 +73,16 @@ ); -

    Regions in {country?.name}

    +

    {$t('worldtravel.regions_in')} {country?.name}

    -
    Region Stats
    -
    {numVisitedRegions}/{numRegions} Visited
    +
    {$t('worldtravel.region_stats')}
    +
    {numVisitedRegions}/{numRegions} {$t('adventures.visited')}
    {#if numRegions === numVisitedRegions} -
    You've visited all regions in {country?.name} 🎉!
    +
    {$t('worldtravel.all_visited')} {country?.name} 🎉!
    {:else} -
    Keep exploring!
    +
    {$t('adventures.keep_exploring')}
    {/if}
    @@ -110,8 +97,12 @@ visitedRegions = [...visitedRegions, e.detail]; numVisitedRegions++; }} - visit_id={visitedRegions.find((visitedRegion) => visitedRegion.region === region.id)?.id} - on:remove={() => numVisitedRegions--} + on:remove={() => { + visitedRegions = visitedRegions.filter( + (visitedRegion) => visitedRegion.region !== region.id + ); + numVisitedRegions--; + }} /> {/each}
    diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts new file mode 100644 index 0000000..b92b47a --- /dev/null +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts @@ -0,0 +1,67 @@ +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +import type { City, Country, Region, VisitedCity, VisitedRegion } from '$lib/types'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const load = (async (event) => { + const id = event.params.id.toUpperCase(); + + let cities: City[] = []; + let region = {} as Region; + let visitedCities: VisitedCity[] = []; + + let sessionId = event.cookies.get('sessionid'); + + if (!sessionId) { + return redirect(302, '/login'); + } + + let res = await fetch(`${endpoint}/api/regions/${id}/cities/`, { + method: 'GET', + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!res.ok) { + console.error('Failed to fetch regions'); + return redirect(302, '/404'); + } else { + cities = (await res.json()) as City[]; + } + + res = await fetch(`${endpoint}/api/regions/${id}/`, { + method: 'GET', + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!res.ok) { + console.error('Failed to fetch country'); + return { status: 500 }; + } else { + region = (await res.json()) as Region; + } + + res = await fetch(`${endpoint}/api/regions/${region.id}/cities/visits/`, { + method: 'GET', + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!res.ok) { + console.error('Failed to fetch visited regions'); + return { status: 500 }; + } else { + visitedCities = (await res.json()) as VisitedCity[]; + } + + return { + props: { + cities, + region, + visitedCities + } + }; +}) satisfies PageServerLoad; diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte new file mode 100644 index 0000000..9801823 --- /dev/null +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte @@ -0,0 +1,223 @@ + + +

    Cities in {data.props?.region.name}

    + +

    + {allCities.length} + Cities Found +

    + +
    +
    +
    +
    City Stats
    +
    {numVisitedCities}/{numCities} Visited
    + {#if numCities === numVisitedCities} +
    You've visited all cities in {data.props?.region.name} 🎉!
    + {:else} +
    Keep exploring!
    + {/if} +
    +
    +
    + + + +{#if allCities.length > 0} +
    + + {#if searchQuery.length > 0} + +
    + +
    + {/if} +
    + +
    + + + + {#each filteredCities as city} + {#if city.latitude && city.longitude} + + + {city.name} + + + {/if} + {/each} + +
    +{/if} + +
    + {#each filteredCities as city} + visitedCity.city === city.id)} + on:visit={(e) => { + visitedCities = [...visitedCities, e.detail]; + }} + on:remove={() => { + visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id); + }} + /> + {/each} +
    + +{#if filteredCities.length === 0} +

    {$t('worldtravel.no_cities_found')}

    +{/if} + + + Cities in {data.props?.region.name} | World Travel + +