feat(auth): enable sign in with user id/email and password
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 1m48s
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 1m48s
This commit is contained in:
parent
8ae1276cd1
commit
a2892a3cda
4 changed files with 265 additions and 96 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue