feat(auth): enable sign in with user id/email and password
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 1m48s

This commit is contained in:
vickytechkey 2026-10-24 15:13:20 +05:30
parent 8ae1276cd1
commit a2892a3cda
4 changed files with 265 additions and 96 deletions

View file

@ -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(),

View file

@ -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)

View file

@ -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 });
}
}
};

View file

@ -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 @@
<div class="v2-auth-card">
<div class="v2-auth-head">
<h1>Sign in</h1>
<p>Welcome back. Choose how you'd like to continue.</p>
<p>Enter your User ID and password to continue.</p>
</div>
<!-- Primary path. Google's mark keeps a white tile so it stays legible on
Ember; the whole button is the one Ember action on this screen. -->
<a
href={data['google_url']}
rel="external"
onclick={handleGoogleLogin}
class="v2-btn v2-btn-primary v2-btn-block"
style:pointer-events={isLoading ? 'none' : null}
style:opacity={isLoading ? '0.85' : null}
{#if errorMessage || data?.error}
<div class="v2-auth-note v2-auth-note-bad" style="margin-bottom:16px">
<TriangleAlert size={16} style="flex:none" />
<span>{errorMessage || data?.error}</span>
</div>
{/if}
<form
method="POST"
use:enhance={handleSubmit}
style="display:flex;flex-direction:column;gap:14px"
>
{#if isLoading}
<span class="v2-spin"></span>
<span>Redirecting…</span>
{:else}
<img src={imgGoogle} alt="" class="v2-auth-gicon" />
<span>Continue with Google</span>
{/if}
</a>
<div class="v2-auth-divider">or</div>
{#if magicLinkSent}
<div class="v2-auth-note v2-auth-note-ok">
<Check />
<div>
<b>Check your email.</b>
<div style="font-weight:400;margin-top:2px">
We sent a sign-in link. It expires in 10 minutes.
</div>
<div style="display:flex;flex-direction:column;gap:5px">
<label for="email" class="v2-label" style="font-size:11px;font-weight:600">User ID / Email</label>
<div style="position:relative;display:flex;align-items:center">
<span style="position:absolute;left:11px;color:var(--v2-slate);display:flex;pointer-events:none">
<User size={15} />
</span>
<input
id="email"
type="text"
name="email"
class="v2-input"
style="padding-left:34px;width:100%"
placeholder="e.g. info@tradhox.com"
required
autocomplete="username"
bind:value={email}
disabled={isSubmitting}
/>
</div>
</div>
{:else}
<form
method="POST"
use:enhance={handleMagicLink}
style="display:flex;flex-direction:column;gap:9px"
>
<label for="email" class="v2-sr-only">Email address</label>
<input
id="email"
type="email"
name="email"
class="v2-input"
placeholder="you@company.com"
required
bind:value={email}
disabled={isSendingLink}
/>
<button type="submit" class="v2-btn v2-btn-block" disabled={isSendingLink}>
{#if isSendingLink}
<span class="v2-spin"></span>
<span>Sending…</span>
{:else}
<Mail size={15} />
<span>Continue with email</span>
{/if}
</button>
</form>
{#if magicLinkError}
<div class="v2-auth-note v2-auth-note-bad" style="margin-top:11px">
<span>{magicLinkError}</span>
<div style="display:flex;flex-direction:column;gap:5px">
<label for="password" class="v2-label" style="font-size:11px;font-weight:600">Password</label>
<div style="position:relative;display:flex;align-items:center">
<span style="position:absolute;left:11px;color:var(--v2-slate);display:flex;pointer-events:none">
<Lock size={15} />
</span>
<input
id="password"
type="password"
name="password"
class="v2-input"
style="padding-left:34px;width:100%"
placeholder="Enter your password"
required
autocomplete="current-password"
bind:value={password}
disabled={isSubmitting}
/>
</div>
{/if}
</div>
<button
type="submit"
class="v2-btn v2-btn-primary v2-btn-block"
style="margin-top:6px"
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="v2-spin"></span>
<span>Signing in…</span>
{:else}
<span>Sign in</span>
{/if}
</button>
</form>
{#if data['google_url']}
<div class="v2-auth-divider">or</div>
<a
href={data['google_url']}
rel="external"
onclick={handleGoogleLogin}
class="v2-btn v2-btn-block"
style:pointer-events={isGoogleLoading ? 'none' : null}
style:opacity={isGoogleLoading ? '0.85' : null}
>
{#if isGoogleLoading}
<span class="v2-spin"></span>
<span>Redirecting…</span>
{:else}
<img src={imgGoogle} alt="" class="v2-auth-gicon" />
<span>Continue with Google</span>
{/if}
</a>
{/if}
</div>
<p class="v2-sub" style="text-align:center;margin:14px 0 0">
New here? Enter your email above to get started.
</p>
<div class="v2-auth-foot">
<a href="https://bottlecrm.io/privacy-policy">Privacy</a>
<span class="v2-auth-dot"></span>