diff --git a/backend/common/urls.py b/backend/common/urls.py index 974ce9e4..5e1f7ba3 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -12,6 +12,7 @@ from common.views.auth_views import ( MeView, OrgAwareTokenRefreshView, OrgSwitchView, + PasswordLoginView, ) from common.views.calendar_feed_views import ( CalendarFeedView, @@ -76,6 +77,11 @@ urlpatterns = [ path("dashboard/", ApiHomeView.as_view()), path("dashboard/today/", ApiTodayView.as_view()), # JWT Authentication endpoints for SvelteKit integration + path( + "auth/login/", + PasswordLoginView.as_view(), + name="password_login", + ), path( "auth/refresh-token/", OrgAwareTokenRefreshView.as_view(), diff --git a/backend/common/views/auth_views.py b/backend/common/views/auth_views.py index 0fc3dacc..f3da9626 100644 --- a/backend/common/views/auth_views.py +++ b/backend/common/views/auth_views.py @@ -1083,3 +1083,105 @@ class MagicLinkVerifyCodeView(APIView): if default_org: response_data["current_org"] = _org_payload(default_org) return Response(response_data, status=status.HTTP_200_OK) + + +class PasswordLoginView(APIView): + """ + Authenticate user with user ID / email and password. + Returns JWT access and refresh tokens. + """ + + permission_classes = [] + authentication_classes = [] + + @extend_schema( + tags=["auth"], + request=inline_serializer( + name="PasswordLoginRequest", + fields={ + "email": serializers.CharField(required=False), + "username": serializers.CharField(required=False), + "password": serializers.CharField(), + }, + ), + responses={ + 200: inline_serializer( + name="PasswordLoginResponse", + fields={ + "access_token": serializers.CharField(), + "refresh_token": serializers.CharField(), + "user": serializers.DictField(), + "organizations": serializers.ListField(), + }, + ) + }, + ) + def post(self, request): + from common.audit_log import audit_log + + identifier = ( + request.data.get("email") + or request.data.get("username") + or request.data.get("user_id") + or "" + ).strip() + password = request.data.get("password") or "" + + if not identifier or not password: + return Response( + {"error": "User ID/Email and password are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.filter(email__iexact=identifier).first() + if not user and "@" not in identifier: + user = User.objects.filter(email__istartswith=identifier + "@").first() + if not user: + user = User.objects.filter(name__iexact=identifier).first() + + if not user or not user.check_password(password): + audit_log.login_failure(identifier, "invalid_credentials", request) + return Response( + {"error": "Invalid User ID/Email or password."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + if not user.is_active: + audit_log.login_failure(user.email, "account_disabled", request) + return _disabled_account_response() + + user.last_login = timezone.now() + user.save(update_fields=["last_login"]) + + profiles = list( + Profile.objects.filter(user=user, is_active=True) + .select_related("org", "user") + .order_by("org__name") + ) + organizations = [_org_payload(p.org, profile=p) for p in profiles] + + default_org = None + profile = None + if len(profiles) == 1: + profile = profiles[0] + default_org = profile.org + elif len(profiles) > 1: + profile = profiles[0] + default_org = profile.org + + token = OrgAwareRefreshToken.for_user_and_org(user, default_org, profile) + + audit_log.login_success(user, default_org, request) + + user_serializer = serializer.UserDetailSerializer(user) + response_data = { + "access_token": str(token.access_token), + "refresh_token": str(token), + "user": user_serializer.data, + "organizations": organizations, + } + if default_org: + response_data["current_org"] = _org_payload(default_org, profile=profile) + + return Response(response_data, status=status.HTTP_200_OK) + diff --git a/frontend/src/routes/(no-layout)/login/+page.server.js b/frontend/src/routes/(no-layout)/login/+page.server.js index 5e87aee7..d7c24553 100644 --- a/frontend/src/routes/(no-layout)/login/+page.server.js +++ b/frontend/src/routes/(no-layout)/login/+page.server.js @@ -11,7 +11,7 @@ */ import axios from 'axios'; -import { redirect } from '@sveltejs/kit'; +import { redirect, fail } from '@sveltejs/kit'; import * as env from '$app/env/private'; import * as publicEnv from '$app/env/public'; import { generateCodeVerifier, generateCodeChallenge, generateState } from '#lib/utils/pkce.js'; @@ -70,6 +70,10 @@ export async function load({ url, cookies, getClientAddress, request }) { // Check if user is already authenticated const jwtAccess = cookies.get('jwt_access'); if (jwtAccess) { + const org = cookies.get('org'); + if (org) { + throw redirect(307, '/'); + } throw redirect(307, '/org'); } @@ -202,32 +206,61 @@ async function generateOAuthUrl(cookies) { /** @type {import('@sveltejs/kit').Actions} */ export const actions = { - default: async ({ request, getClientAddress }) => { + default: async ({ request, cookies, getClientAddress }) => { const formData = await request.formData(); - const email = formData.get('email'); + const email = (formData.get('email') || formData.get('username') || '').toString().trim(); + const password = (formData.get('password') || '').toString(); - if (!email) { - return { success: false, error: 'Email is required' }; + if (!email || !password) { + return fail(400, { error: 'Please enter both User ID/Email and password.', email }); } try { const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; - await axios.post( - `${apiUrl}/api/auth/magic-link/request/`, - { email }, + const response = await axios.post( + `${apiUrl}/api/auth/login/`, + { email, password }, { - // The token records who asked for it; see `#lib/server/relay.js`. headers: { 'Content-Type': 'application/json', ...relayHeaders({ getClientAddress, request }) }, - timeout: 10000 + timeout: 15000 } ); - return { success: true }; - } catch { - // Always show success to user (backend also returns 200 always) - return { success: true }; + + const { access_token, refresh_token, current_org, organizations } = response.data; + + // Store JWT tokens in secure httpOnly cookies + cookies.set('jwt_access', access_token, getCookieOptions(60 * 60 * 24)); // 1 day + cookies.set('jwt_refresh', refresh_token, getCookieOptions(60 * 60 * 24 * 365)); // 1 year + + // If user has an org, set the org cookie directly + const orgToSet = + current_org?.id || (organizations && organizations.length === 1 ? organizations[0].id : null); + if (orgToSet) { + cookies.set('org', orgToSet, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 365 + }); + throw redirect(303, '/'); + } + + // If user has multiple orgs or none, go to /org selector + throw redirect(303, '/org'); + } catch (error) { + if (error?.status === 303 || error?.status === 307) { + throw error; + } + console.error('Password login failed:', describeError(error)); + const errorMsg = + error.response?.data?.error || + error.response?.data?.detail || + 'Invalid User ID or password. Please try again.'; + return fail(400, { error: errorMsg, email }); } } }; diff --git a/frontend/src/routes/(no-layout)/login/+page.svelte b/frontend/src/routes/(no-layout)/login/+page.svelte index f8b7f583..8f734551 100644 --- a/frontend/src/routes/(no-layout)/login/+page.svelte +++ b/frontend/src/routes/(no-layout)/login/+page.svelte @@ -6,31 +6,38 @@ import imgGoogle from '#lib/assets/images/google.svg'; import imgLogo from '#lib/assets/images/logo.png'; - import { Mail, Check } from '@lucide/svelte'; + import { Lock, User, TriangleAlert } from '@lucide/svelte'; - let { data = {} } = $props(); + let { data = {}, form } = $props(); - let isLoading = $state(false); + let isSubmitting = $state(false); + let isGoogleLoading = $state(false); let email = $state(''); - let magicLinkSent = $state(false); - let isSendingLink = $state(false); - let magicLinkError = $state(''); + let password = $state(''); + let errorMessage = $state(''); + + $effect(() => { + if (form?.error) { + errorMessage = form.error; + } + if (form?.email && !email) { + email = form.email; + } + }); function handleGoogleLogin() { - isLoading = true; + isGoogleLoading = true; } - function handleMagicLink() { - isSendingLink = true; - magicLinkError = ''; - return async ({ result }) => { - isSendingLink = false; - if (result?.type === 'success') { - magicLinkSent = true; - } else if (result?.type === 'failure') { - magicLinkError = result.data?.error || 'Something went wrong. Please try again.'; - } else if (!result) { - magicLinkError = 'Something went wrong. Please try again.'; + function handleSubmit() { + isSubmitting = true; + errorMessage = ''; + return async ({ result, update }) => { + isSubmitting = false; + if (result?.type === 'failure') { + errorMessage = result.data?.error || 'Invalid credentials. Please try again.'; + } else { + await update(); } }; } @@ -54,79 +61,100 @@
Welcome back. Choose how you'd like to continue.
+Enter your User ID and password to continue.