mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-08-05 13:15:18 +02:00
feat: add Wanderer integration with authentication and management features
This commit is contained in:
parent
2c23b6ca57
commit
9fd06911b2
14 changed files with 462 additions and 26 deletions
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 5.2.2 on 2025-08-04 16:40
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0058_alter_collectioninvite_options_activity'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='activity',
|
||||
options={'verbose_name': 'Activity', 'verbose_name_plural': 'Activities'},
|
||||
),
|
||||
]
|
|
@ -1,10 +1,11 @@
|
|||
from django.contrib import admin
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
|
||||
from .models import ImmichIntegration, StravaToken
|
||||
from .models import ImmichIntegration, StravaToken, WandererIntegration
|
||||
|
||||
admin.autodiscover()
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
admin.site.register(ImmichIntegration)
|
||||
admin.site.register(StravaToken)
|
||||
admin.site.register(WandererIntegration)
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 5.2.2 on 2025-08-04 16:40
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('integrations', '0003_stravatoken'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WandererIntegration',
|
||||
fields=[
|
||||
('server_url', models.CharField(max_length=255)),
|
||||
('username', models.CharField(max_length=255)),
|
||||
('token', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('token_expiry', models.DateTimeField(blank=True, null=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wanderer_integrations', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Wanderer Integration',
|
||||
'verbose_name_plural': 'Wanderer Integrations',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.2 on 2025-08-04 16:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('integrations', '0004_wandererintegration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='wandererintegration',
|
||||
name='token',
|
||||
field=models.CharField(blank=True, max_length=1000, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.2 on 2025-08-04 17:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('integrations', '0005_alter_wandererintegration_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='wandererintegration',
|
||||
name='token',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -24,3 +24,19 @@ class StravaToken(models.Model):
|
|||
athlete_id = models.BigIntegerField(null=True, blank=True)
|
||||
scope = models.CharField(max_length=255, null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class WandererIntegration(models.Model):
|
||||
server_url = models.CharField(max_length=255)
|
||||
username = models.CharField(max_length=255)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name='wanderer_integrations')
|
||||
token = models.CharField(null=True, blank=True)
|
||||
token_expiry = models.DateTimeField(null=True, blank=True)
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.user.username + ' - ' + self.server_url
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Wanderer Integration"
|
||||
verbose_name_plural = "Wanderer Integrations"
|
|
@ -1,7 +1,7 @@
|
|||
from integrations.views import *
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from integrations.views import IntegrationView, StravaIntegrationView
|
||||
from integrations.views import IntegrationView, StravaIntegrationView, WandererIntegrationViewSet
|
||||
|
||||
# Create the router and register the ViewSet
|
||||
router = DefaultRouter()
|
||||
|
@ -9,6 +9,7 @@ router.register(r'immich', ImmichIntegrationView, basename='immich')
|
|||
router.register(r'', IntegrationView, basename='integrations')
|
||||
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
|
||||
router.register(r'strava', StravaIntegrationView, basename='strava')
|
||||
router.register(r'wanderer', WandererIntegrationViewSet, basename='wanderer')
|
||||
|
||||
# Include the router URLs
|
||||
urlpatterns = [
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet
|
||||
from .integration_view import IntegrationView
|
||||
from .strava_view import StravaIntegrationView
|
||||
from .wanderer_view import WandererIntegrationViewSet
|
|
@ -2,7 +2,7 @@ import os
|
|||
from rest_framework.response import Response
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from integrations.models import ImmichIntegration, StravaToken
|
||||
from integrations.models import ImmichIntegration, StravaToken, WandererIntegration
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
|
@ -16,6 +16,7 @@ class IntegrationView(viewsets.ViewSet):
|
|||
google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
|
||||
strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != ''
|
||||
strava_integration_user = StravaToken.objects.filter(user=request.user).exists()
|
||||
wanderer_integration = WandererIntegration.objects.filter(user=request.user).exists()
|
||||
|
||||
return Response(
|
||||
{
|
||||
|
@ -24,7 +25,8 @@ class IntegrationView(viewsets.ViewSet):
|
|||
'strava': {
|
||||
'global': strava_integration_global,
|
||||
'user': strava_integration_user
|
||||
}
|
||||
},
|
||||
'wanderer': wanderer_integration
|
||||
},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
|
141
backend/server/integrations/views/wanderer_view.py
Normal file
141
backend/server/integrations/views/wanderer_view.py
Normal file
|
@ -0,0 +1,141 @@
|
|||
# views.py
|
||||
import requests
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import ValidationError, NotFound
|
||||
|
||||
from integrations.models import WandererIntegration
|
||||
from integrations.wanderer_services import get_valid_session, login_to_wanderer, IntegrationError
|
||||
from django.utils import timezone
|
||||
|
||||
class WandererIntegrationViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def _get_obj(self):
|
||||
try:
|
||||
return WandererIntegration.objects.filter(user=self.request.user).first()
|
||||
except WandererIntegration.DoesNotExist:
|
||||
raise NotFound("Wanderer integration not found.")
|
||||
|
||||
# def list(self, request):
|
||||
# try:
|
||||
# inst = self._get_obj()
|
||||
# except NotFound:
|
||||
# return Response([], status=status.HTTP_200_OK)
|
||||
# return Response({
|
||||
# "id": inst.id,
|
||||
# "server_url": inst.server_url,
|
||||
# "username": inst.username,
|
||||
# "is_connected": bool(inst.token and inst.token_expiry and inst.token_expiry > timezone.now()),
|
||||
# "token_expiry": inst.token_expiry,
|
||||
# })
|
||||
|
||||
def create(self, request):
|
||||
if WandererIntegration.objects.filter(user=request.user).exists():
|
||||
raise ValidationError("Wanderer integration already exists. Use UPDATE instead.")
|
||||
|
||||
server_url = request.data.get("server_url")
|
||||
username = request.data.get("username")
|
||||
password = request.data.get("password")
|
||||
if not server_url or not username or not password:
|
||||
raise ValidationError(
|
||||
"Must provide server_url, username + password in request data."
|
||||
)
|
||||
|
||||
inst = WandererIntegration(
|
||||
user=request.user,
|
||||
server_url=server_url.rstrip("/"),
|
||||
username=username,
|
||||
)
|
||||
|
||||
try:
|
||||
token, expiry = login_to_wanderer(inst, password)
|
||||
except IntegrationError as e:
|
||||
raise ValidationError({"error": str(e)})
|
||||
|
||||
inst.token = token
|
||||
inst.token_expiry = expiry
|
||||
inst.save()
|
||||
|
||||
return Response(
|
||||
{"message": "Wanderer integration created and authenticated successfully."},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
def update(self, request, pk=None):
|
||||
inst = self._get_obj()
|
||||
changed = False
|
||||
for field in ("server_url", "username"):
|
||||
if field in request.data and getattr(inst, field) != request.data[field]:
|
||||
setattr(inst, field, request.data[field].rstrip("/") if field=="server_url" else request.data[field])
|
||||
changed = True
|
||||
|
||||
password = request.data.get("password")
|
||||
if not changed and not password:
|
||||
return Response(
|
||||
{"detail": "Nothing updated: send at least one of server_url, username, or password."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# If password provided: re-auth / token renewal
|
||||
if password:
|
||||
try:
|
||||
token, expiry = login_to_wanderer(inst, password)
|
||||
except IntegrationError as e:
|
||||
raise ValidationError({"error": str(e)})
|
||||
inst.token = token
|
||||
inst.token_expiry = expiry
|
||||
|
||||
inst.save()
|
||||
return Response({"message": "Integration updated successfully."})
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def disable(self, request):
|
||||
inst = self._get_obj()
|
||||
inst.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def refresh(self, request):
|
||||
inst = self._get_obj()
|
||||
|
||||
password = request.data.get("password")
|
||||
try:
|
||||
session = get_valid_session(inst, password_for_reauth=password)
|
||||
except IntegrationError as e:
|
||||
raise ValidationError({"detail": str(e)})
|
||||
|
||||
return Response({
|
||||
"token": inst.token,
|
||||
"token_expiry": inst.token_expiry,
|
||||
"is_connected": True,
|
||||
})
|
||||
|
||||
@action(detail=False, methods=["get"], url_path='list')
|
||||
def list_trails(self, request):
|
||||
inst = self._get_obj()
|
||||
|
||||
# Check if we need to prompt for password
|
||||
password = request.query_params.get("password") # Allow password via query param if needed
|
||||
|
||||
try:
|
||||
session = get_valid_session(inst, password_for_reauth=password)
|
||||
except IntegrationError as e:
|
||||
# If session expired and no password provided, give a helpful error
|
||||
if "password is required" in str(e).lower():
|
||||
raise ValidationError({
|
||||
"detail": str(e),
|
||||
"requires_password": True
|
||||
})
|
||||
raise ValidationError({"detail": str(e)})
|
||||
|
||||
url = f"{inst.server_url.rstrip('/')}/api/v1/list"
|
||||
try:
|
||||
response = session.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise ValidationError({"detail": f"Error fetching trails: {exc}"})
|
||||
|
||||
return Response(response.json())
|
111
backend/server/integrations/wanderer_services.py
Normal file
111
backend/server/integrations/wanderer_services.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
# wanderer_services.py
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from datetime import timezone as dt_timezone
|
||||
from django.utils import timezone as django_timezone
|
||||
import logging
|
||||
|
||||
from .models import WandererIntegration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class IntegrationError(Exception):
|
||||
pass
|
||||
|
||||
# Use both possible cookie names
|
||||
COOKIE_NAMES = ("pb_auth", "pb-auth")
|
||||
LOGIN_PATH = "/api/v1/auth/login"
|
||||
|
||||
def login_to_wanderer(integration: WandererIntegration, password: str):
|
||||
"""
|
||||
Authenticate with Wanderer and return the auth cookie and expiry.
|
||||
"""
|
||||
url = integration.server_url.rstrip("/") + LOGIN_PATH
|
||||
|
||||
try:
|
||||
resp = requests.post(url, json={
|
||||
"username": integration.username,
|
||||
"password": password
|
||||
}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
logger.error("Error connecting to Wanderer login: %s", exc)
|
||||
raise IntegrationError("Could not connect to Wanderer server.")
|
||||
|
||||
# Log only summary (not full token body)
|
||||
logger.debug("Wanderer login status: %s, headers: %s", resp.status_code, resp.headers.get("Set-Cookie"))
|
||||
|
||||
# Extract auth cookie and expiry
|
||||
token = None
|
||||
expiry = None
|
||||
for cookie in resp.cookies:
|
||||
if cookie.name in COOKIE_NAMES:
|
||||
token = cookie.value
|
||||
if cookie.expires:
|
||||
expiry = datetime.fromtimestamp(cookie.expires, tz=dt_timezone.utc)
|
||||
else:
|
||||
# If no expiry set, assume 24 hours from now
|
||||
expiry = django_timezone.now() + django_timezone.timedelta(hours=24)
|
||||
break
|
||||
|
||||
if not token:
|
||||
logger.error("Wanderer login succeeded but no auth cookie in response.")
|
||||
raise IntegrationError("Authentication succeeded, but token cookie not found.")
|
||||
|
||||
logger.info(f"Successfully authenticated with Wanderer. Token expires: {expiry}")
|
||||
return token, expiry
|
||||
|
||||
def get_valid_session(integration: WandererIntegration, password_for_reauth: str = None):
|
||||
"""
|
||||
Get a requests session with valid authentication.
|
||||
Will reuse existing token if valid, or re-authenticate if needed.
|
||||
"""
|
||||
now = django_timezone.now()
|
||||
session = requests.Session()
|
||||
|
||||
# Check if we have a valid token
|
||||
if integration.token and integration.token_expiry and integration.token_expiry > now:
|
||||
logger.debug("Using existing valid token")
|
||||
session.cookies.set(COOKIE_NAMES[0], integration.token)
|
||||
return session
|
||||
|
||||
# Token expired or missing - need to re-authenticate
|
||||
if password_for_reauth is None:
|
||||
raise IntegrationError("Session expired; password is required to reconnect.")
|
||||
|
||||
logger.info("Token expired, re-authenticating with Wanderer")
|
||||
token, expiry = login_to_wanderer(integration, password_for_reauth)
|
||||
|
||||
# Update the integration with new token
|
||||
integration.token = token
|
||||
integration.token_expiry = expiry
|
||||
integration.save(update_fields=["token", "token_expiry"])
|
||||
|
||||
# Set the cookie in the session
|
||||
session.cookies.set(COOKIE_NAMES[0], token)
|
||||
return session
|
||||
|
||||
def make_wanderer_request(integration: WandererIntegration, endpoint: str, method: str = "GET", password_for_reauth: str = None, **kwargs):
|
||||
"""
|
||||
Helper function to make authenticated requests to Wanderer API.
|
||||
|
||||
Args:
|
||||
integration: WandererIntegration instance
|
||||
endpoint: API endpoint (e.g., '/api/v1/list')
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
password_for_reauth: Password to use if re-authentication is needed
|
||||
**kwargs: Additional arguments to pass to requests method
|
||||
|
||||
Returns:
|
||||
requests.Response object
|
||||
"""
|
||||
session = get_valid_session(integration, password_for_reauth)
|
||||
url = f"{integration.server_url.rstrip('/')}{endpoint}"
|
||||
|
||||
try:
|
||||
response = getattr(session, method.lower())(url, timeout=10, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"Error making {method} request to {url}: {exc}")
|
||||
raise IntegrationError(f"Error communicating with Wanderer: {exc}")
|
|
@ -799,5 +799,10 @@
|
|||
"authorization_error": "Error redirecting to strava authorization URL",
|
||||
"disconnected": "Successfully disconnected from Strava",
|
||||
"disconnect_error": "Error disconnecting from Strava"
|
||||
},
|
||||
"wanderer": {
|
||||
"wanderer_integration_desc": "Connect to Wanderer to easily import and view your trails in locations",
|
||||
"connection_error": "Error connecting to Wanderer",
|
||||
"connected": "Successfully connected to Wanderer"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,6 +82,7 @@ export const load: PageServerLoad = async (event) => {
|
|||
let googleMapsEnabled = integrations.google_maps as boolean;
|
||||
let stravaGlobalEnabled = integrations.strava.global as boolean;
|
||||
let stravaUserEnabled = integrations.strava.user as boolean;
|
||||
let wandererEnabled = integrations.wanderer as boolean;
|
||||
|
||||
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
|
||||
let publicUrl = '';
|
||||
|
@ -102,7 +103,8 @@ export const load: PageServerLoad = async (event) => {
|
|||
socialProviders,
|
||||
googleMapsEnabled,
|
||||
stravaGlobalEnabled,
|
||||
stravaUserEnabled
|
||||
stravaUserEnabled,
|
||||
wandererEnabled
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
let googleMapsEnabled = data.props.googleMapsEnabled;
|
||||
let stravaGlobalEnabled = data.props.stravaGlobalEnabled;
|
||||
let stravaUserEnabled = data.props.stravaUserEnabled;
|
||||
let wandererEnabled = data.props.wandererEnabled;
|
||||
let activeSection: string = 'profile';
|
||||
|
||||
// Initialize activeSection from URL on mount
|
||||
|
@ -55,6 +56,12 @@
|
|||
copy_locally: true
|
||||
};
|
||||
|
||||
let newWandererIntegration = {
|
||||
server_url: '',
|
||||
username: '',
|
||||
password: ''
|
||||
};
|
||||
|
||||
let isMFAModalOpen: boolean = false;
|
||||
|
||||
const sections = [
|
||||
|
@ -321,6 +328,39 @@
|
|||
addToast('error', $t('strava.disconnect_error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function wandererDisconnect() {
|
||||
const res = await fetch('/api/integrations/wanderer/disable/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
addToast('success', $t('wanderer.disconnected'));
|
||||
wandererEnabled = false;
|
||||
} else {
|
||||
addToast('error', $t('wanderer.disconnect_error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function wandererConnect() {
|
||||
const res = await fetch('/api/integrations/wanderer/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(newWandererIntegration)
|
||||
});
|
||||
if (res.ok) {
|
||||
addToast('success', $t('wanderer.connected'));
|
||||
wandererEnabled = true;
|
||||
newWandererIntegration = { server_url: '', username: '', password: '' };
|
||||
} else {
|
||||
const data = await res.json();
|
||||
addToast('error', $t('wanderer.connection_error'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isMFAModalOpen}
|
||||
|
@ -1054,10 +1094,10 @@
|
|||
<div>
|
||||
<h3 class="text-xl font-bold">Wanderer</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
{$t('strava.strava_integration_desc')}
|
||||
{$t('wanderer.wanderer_integration_desc')}
|
||||
</p>
|
||||
</div>
|
||||
{#if stravaGlobalEnabled && stravaUserEnabled}
|
||||
{#if wandererEnabled}
|
||||
<div class="badge badge-success ml-auto">{$t('settings.connected')}</div>
|
||||
{:else}
|
||||
<div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div>
|
||||
|
@ -1065,25 +1105,56 @@
|
|||
</div>
|
||||
|
||||
<!-- Content based on integration status -->
|
||||
{#if !stravaGlobalEnabled}
|
||||
<!-- Strava not enabled globally -->
|
||||
<div class="text-center">
|
||||
<p class="text-base-content/70 mb-4">
|
||||
{$t('strava.not_enabled') ||
|
||||
'Strava integration is not enabled on this instance.'}
|
||||
</p>
|
||||
{#if !wandererEnabled}
|
||||
<!-- login form with server url username and password -->
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Server URL</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
class="input input-bordered input-primary focus:input-primary"
|
||||
placeholder="https://wanderer.example.com"
|
||||
bind:value={newWandererIntegration.server_url}
|
||||
/>
|
||||
</div>
|
||||
{:else if !stravaUserEnabled && stravaGlobalEnabled}
|
||||
<!-- Globally enabled but user not connected -->
|
||||
<div class="text-center">
|
||||
<button class="btn btn-primary" on:click={stravaAuthorizeRedirect}>
|
||||
🔗 {$t('strava.connect_account')}
|
||||
|
||||
<div class="form-control">
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Username</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-primary focus:input-primary"
|
||||
placeholder="Enter your username"
|
||||
bind:value={newWandererIntegration.username}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
class="input input-bordered input-primary focus:input-primary"
|
||||
placeholder="Enter your password"
|
||||
bind:value={newWandererIntegration.password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary w-full" on:click={wandererConnect}>
|
||||
🔗 Connect to Wanderer
|
||||
</button>
|
||||
</div>
|
||||
{:else if stravaGlobalEnabled && stravaUserEnabled}
|
||||
{:else}
|
||||
<!-- User connected - show management options -->
|
||||
<div class="text-center">
|
||||
<button class="btn btn-error" on:click={stravaDisconnect}>
|
||||
<button class="btn btn-error" on:click={wandererDisconnect}>
|
||||
❌ {$t('strava.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -1097,7 +1168,7 @@
|
|||
📖 {$t('immich.need_help')}
|
||||
<a
|
||||
class="link link-primary"
|
||||
href="https://adventurelog.app/docs/configuration/strava_integration.html"
|
||||
href="https://adventurelog.app/docs/configuration/wanderer_integration.html"
|
||||
target="_blank">{$t('navbar.documentation')}</a
|
||||
>
|
||||
</p>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue