feat: add auth context, route guards, password reset, and API client
- Add AuthProvider context and RequireAuth route guard - Create API client with all endpoint helpers - Add forgot-password route wired to backend reset endpoints - Remove dummy login button from login page - Wire dashboard to auth context for user data - Enhance profile save to send all fields - Wire profile submit-for-verification to backend API
This commit is contained in:
parent
19a0850c49
commit
e5406a0061
7 changed files with 589 additions and 94 deletions
|
|
@ -2,6 +2,7 @@ import { MetaProvider, Title } from '@solidjs/meta';
|
||||||
import { Router } from '@solidjs/router';
|
import { Router } from '@solidjs/router';
|
||||||
import { FileRoutes } from '@solidjs/start/router';
|
import { FileRoutes } from '@solidjs/start/router';
|
||||||
import { ErrorBoundary, Suspense } from 'solid-js';
|
import { ErrorBoundary, Suspense } from 'solid-js';
|
||||||
|
import { AuthProvider } from '~/lib/auth';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
@ -10,6 +11,7 @@ export default function App() {
|
||||||
root={(props) => (
|
root={(props) => (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Title>NXTGAUGE Frontend Solid</Title>
|
<Title>NXTGAUGE Frontend Solid</Title>
|
||||||
|
<AuthProvider>
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={(err) => (
|
fallback={(err) => (
|
||||||
<main style={{ padding: '24px', 'font-family': 'Inter, system-ui, sans-serif', color: '#111827', background: '#fff' }}>
|
<main style={{ padding: '24px', 'font-family': 'Inter, system-ui, sans-serif', color: '#111827', background: '#fff' }}>
|
||||||
|
|
@ -23,6 +25,7 @@ export default function App() {
|
||||||
>
|
>
|
||||||
<Suspense>{props.children}</Suspense>
|
<Suspense>{props.children}</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
</AuthProvider>
|
||||||
</MetaProvider>
|
</MetaProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1686,9 +1686,21 @@ export default function DashboardDesignPreview(props: {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitProfileForApproval = () => {
|
const submitProfileForApproval = async () => {
|
||||||
|
if (!hasLive() || !livePrefix()) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${GW}/api/${livePrefix()}/profile/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
setProfileApprovalState('SUBMITTED');
|
setProfileApprovalState('SUBMITTED');
|
||||||
setTimeout(() => setProfileApprovalState('IN_REVIEW'), 250);
|
setTimeout(() => setProfileApprovalState('IN_REVIEW'), 250);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setProfileApprovalState('SUBMITTED');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const submitPortfolioForApproval = () => {
|
const submitPortfolioForApproval = () => {
|
||||||
setPortfolioApprovalState('SUBMITTED');
|
setPortfolioApprovalState('SUBMITTED');
|
||||||
|
|
@ -3407,11 +3419,19 @@ export default function DashboardDesignPreview(props: {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
display_name: display || undefined,
|
display_name: display || undefined,
|
||||||
|
full_name: display || undefined,
|
||||||
|
first_name: fn || undefined,
|
||||||
|
last_name: ln || undefined,
|
||||||
|
business_name: data['Business Name'] || undefined,
|
||||||
|
company_name: data['Company Name'] || undefined,
|
||||||
|
contact_person: data['Contact Person Name'] || undefined,
|
||||||
bio: data['Bio'] || undefined,
|
bio: data['Bio'] || undefined,
|
||||||
location: data['City'] || undefined,
|
location: data['City'] || undefined,
|
||||||
area: data['Area'] || undefined,
|
area: data['Area'] || undefined,
|
||||||
state: data['State'] || undefined,
|
state: data['State'] || undefined,
|
||||||
pin_code: data['PIN Code'] || undefined,
|
pin_code: data['PIN Code'] || undefined,
|
||||||
|
phone: data['Mobile Number'] || undefined,
|
||||||
|
email: data['Email Address'] || undefined,
|
||||||
}),
|
}),
|
||||||
}).then((r) => {
|
}).then((r) => {
|
||||||
setProfileSaving(false);
|
setProfileSaving(false);
|
||||||
|
|
|
||||||
180
src/lib/api.ts
Normal file
180
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
const API = '/api/gateway';
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
const token = typeof window !== 'undefined'
|
||||||
|
? (sessionStorage.getItem('nxtgauge_access_token') || localStorage.getItem('nxtgauge_access_token') || '')
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, options?: RequestInit): Promise<any> {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
credentials: 'include',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`API error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProfile(rolePrefix: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/profile/me`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProfile(rolePrefix: string, payload: any): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/profile/me`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitProfileForVerification(rolePrefix: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/profile/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPortfolio(rolePrefix: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/portfolio`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPortfolioItem(rolePrefix: string, payload: any): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/portfolio`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePortfolioItem(rolePrefix: string, id: string, payload: any): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/portfolio/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePortfolioItem(rolePrefix: string, id: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/portfolio/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServices(rolePrefix: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/services`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createService(rolePrefix: string, payload: any): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/services`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteService(rolePrefix: string, id: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/services/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWallet(rolePrefix: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/wallet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLedger(rolePrefix: string, page = 1, limit = 20): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/wallet/ledger?page=${page}&limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInvoices(rolePrefix: string, page = 1, limit = 20): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/wallet/invoices?page=${page}&limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPaymentOrder(amount: number, packageId?: string): Promise<any> {
|
||||||
|
return apiFetch('/api/payments/create-order', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ amount, package_id: packageId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPayment(orderId: string, paymentId: string): Promise<any> {
|
||||||
|
return apiFetch('/api/payments/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ order_id: orderId, payment_id: paymentId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPaymentStatus(paymentId: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/payments/${paymentId}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJobs(rolePrefix: string, params?: Record<string, string>): Promise<any> {
|
||||||
|
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||||
|
return apiFetch(`/api/${rolePrefix}/jobs${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyToJob(rolePrefix: string, jobId: string, payload: any): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/jobs/${jobId}/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyApplications(rolePrefix: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/applications`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRequirements(rolePrefix: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/requirements`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRequirement(rolePrefix: string, payload: any): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/requirements`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMarketplace(rolePrefix: string, page = 1, limit = 20): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/marketplace?page=${page}&limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLeadRequest(rolePrefix: string, payload: any): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/leads/request`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyLeadRequests(rolePrefix: string): Promise<any> {
|
||||||
|
return apiFetch(`/api/${rolePrefix}/leads/requests`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadDocument(rolePrefix: string, file: File, documentType: string): Promise<any> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('document_type', documentType);
|
||||||
|
|
||||||
|
const token = typeof window !== 'undefined'
|
||||||
|
? (sessionStorage.getItem('nxtgauge_access_token') || localStorage.getItem('nxtgauge_access_token') || '')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const res = await fetch(`${API}/api/${rolePrefix}/profile/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Upload error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
116
src/lib/auth.tsx
Normal file
116
src/lib/auth.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { createContext, createResource, createSignal, useContext, type ParentProps, type Accessor, type Setter } from 'solid-js';
|
||||||
|
import { useNavigate } from '@solidjs/router';
|
||||||
|
|
||||||
|
const API = '/api/gateway';
|
||||||
|
|
||||||
|
type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
active_role: string;
|
||||||
|
email_verified: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
user: Accessor<AuthUser | null>;
|
||||||
|
isAuthenticated: Accessor<boolean>;
|
||||||
|
isLoading: Accessor<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
setUser: Setter<AuthUser | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState>();
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return sessionStorage.getItem('nxtgauge_access_token')
|
||||||
|
|| localStorage.getItem('nxtgauge_access_token')
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthStorage() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
sessionStorage.removeItem('nxtgauge_access_token');
|
||||||
|
sessionStorage.removeItem('nxtgauge_frontend_access_token');
|
||||||
|
localStorage.removeItem('nxtgauge_access_token');
|
||||||
|
localStorage.removeItem('nxtgauge_auth_user');
|
||||||
|
localStorage.removeItem('nxtgauge_user');
|
||||||
|
localStorage.removeItem('nxtgauge_signup_profile_v1');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSession(): Promise<AuthUser | null> {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/auth/session`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.id && !data?.user_id) return null;
|
||||||
|
return {
|
||||||
|
id: data.id || data.user_id,
|
||||||
|
email: data.email || '',
|
||||||
|
full_name: data.full_name || data.name || '',
|
||||||
|
active_role: data.active_role || data.role || 'JOB_SEEKER',
|
||||||
|
email_verified: data.email_verified || false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider(props: ParentProps) {
|
||||||
|
const [user, setUser] = createSignal<AuthUser | null>(null);
|
||||||
|
const [session] = createResource(fetchSession);
|
||||||
|
|
||||||
|
const isLoading = () => session.loading;
|
||||||
|
const isAuthenticated = () => !!user() || (!!session() && !session.error);
|
||||||
|
|
||||||
|
if (session()) {
|
||||||
|
setUser(session() as AuthUser | null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
clearAuthStorage();
|
||||||
|
setUser(null);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, isAuthenticated, isLoading, logout, setUser }}>
|
||||||
|
{props.children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequireAuth(props: ParentProps<{ fallback?: string }>) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const auth = useAuth();
|
||||||
|
|
||||||
|
if (auth.isLoading()) {
|
||||||
|
return <div class="flex min-h-screen items-center justify-center text-[#6B7280]">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated()) {
|
||||||
|
const fallback = props.fallback || '/login';
|
||||||
|
navigate(fallback, { replace: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{props.children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getToken, clearAuthStorage };
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { Show, createEffect, createMemo, createResource, createSignal, onMount } from 'solid-js';
|
import { Show, createEffect, createMemo, createResource, createSignal, onMount } from 'solid-js';
|
||||||
|
import { useNavigate } from '@solidjs/router';
|
||||||
|
import { useAuth, RequireAuth } from '~/lib/auth';
|
||||||
import DashboardDesignPreview from '~/components/admin/DashboardDesignPreview';
|
import DashboardDesignPreview from '~/components/admin/DashboardDesignPreview';
|
||||||
|
|
||||||
type RoleKey =
|
type RoleKey =
|
||||||
|
|
@ -212,6 +214,8 @@ function mergeSidebar(role: RoleKey, runtimeSidebar: string[]): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RuntimeDashboardPage() {
|
export default function RuntimeDashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const auth = useAuth();
|
||||||
const [hydrated, setHydrated] = createSignal(false);
|
const [hydrated, setHydrated] = createSignal(false);
|
||||||
const [role, setRole] = createSignal<RoleKey>('JOB_SEEKER');
|
const [role, setRole] = createSignal<RoleKey>('JOB_SEEKER');
|
||||||
const [activeSidebar, setActiveSidebar] = createSignal('My Dashboard');
|
const [activeSidebar, setActiveSidebar] = createSignal('My Dashboard');
|
||||||
|
|
@ -221,20 +225,24 @@ export default function RuntimeDashboardPage() {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setHydrated(true);
|
setHydrated(true);
|
||||||
setRole(getInitialRoleFromStorage());
|
const storedRole = getInitialRoleFromStorage();
|
||||||
|
setRole(storedRole);
|
||||||
setUserName(getNameFromStorage());
|
setUserName(getNameFromStorage());
|
||||||
// Fetch fresh session data
|
if (auth.user()) {
|
||||||
fetchJson('/api/auth/session').then((data) => {
|
const u = auth.user()!;
|
||||||
if (!data) return;
|
if (u.full_name) setUserName(u.full_name);
|
||||||
const name = data.full_name
|
if (u.id) setUserId(u.id);
|
||||||
|| data.name
|
if (u.active_role) setRole(normalizeRole(u.active_role));
|
||||||
|| (data.first_name ? `${data.first_name} ${data.last_name ?? ''}`.trim() : '')
|
}
|
||||||
|| data.email?.split('@')[0]
|
|
||||||
|| '';
|
|
||||||
if (name) setUserName(name);
|
|
||||||
if (data.id || data.user_id) setUserId(String(data.id || data.user_id));
|
|
||||||
if (data.active_role) setRole(normalizeRole(data.active_role));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const u = auth.user();
|
||||||
|
if (u) {
|
||||||
|
if (u.full_name && userName() === 'User') setUserName(u.full_name);
|
||||||
|
if (u.id && !userId()) setUserId(u.id);
|
||||||
|
if (u.active_role) setRole(normalizeRole(u.active_role));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [bundle] = createResource(
|
const [bundle] = createResource(
|
||||||
|
|
@ -272,6 +280,7 @@ export default function RuntimeDashboardPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<RequireAuth>
|
||||||
<main style={{ 'min-height': '100vh', background: '#f3f4f6' }}>
|
<main style={{ 'min-height': '100vh', background: '#f3f4f6' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
|
|
@ -299,6 +308,7 @@ export default function RuntimeDashboardPage() {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</RequireAuth>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
194
src/routes/forgot-password.tsx
Normal file
194
src/routes/forgot-password.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { A, useNavigate, useSearchParams } from '@solidjs/router';
|
||||||
|
import { createMemo, createSignal, Show } from 'solid-js';
|
||||||
|
import PublicBackground from '~/components/PublicBackground';
|
||||||
|
import PublicHeader from '~/components/PublicHeader';
|
||||||
|
import { isValidEmail } from '~/lib/form-validation';
|
||||||
|
|
||||||
|
function PasswordVisibilityIcon(props: { visible: boolean }) {
|
||||||
|
if (props.visible) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
<path d="M10.58 10.58a2 2 0 0 0 2.83 2.83" />
|
||||||
|
<path d="M9.88 5.09A11 11 0 0 1 12 4.9c5.5 0 10 4.1 10 7.1 0 1.2-.72 2.53-1.95 3.72" />
|
||||||
|
<path d="M6.1 6.1C3.54 7.58 2 9.79 2 12c0 3 4.48 7.1 10 7.1 1.72 0 3.36-.4 4.84-1.12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForgotPasswordRoute() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search] = useSearchParams();
|
||||||
|
const [email, setEmail] = createSignal('');
|
||||||
|
const [token, setToken] = createSignal(search.token || '');
|
||||||
|
const [newPassword, setNewPassword] = createSignal('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||||
|
const [showPassword, setShowPassword] = createSignal(false);
|
||||||
|
const [showConfirm, setShowConfirm] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal('');
|
||||||
|
const [success, setSuccess] = createSignal('');
|
||||||
|
const [submitting, setSubmitting] = createSignal(false);
|
||||||
|
const [step, setStep] = createSignal<'request' | 'reset'>(token() ? 'reset' : 'request');
|
||||||
|
|
||||||
|
const passwordsMatch = createMemo(() => newPassword() === confirmPassword() && newPassword().length >= 8);
|
||||||
|
|
||||||
|
const requestReset = async () => {
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
if (!isValidEmail(email())) {
|
||||||
|
setError('Enter a valid email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gateway/api/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email().trim().toLowerCase() }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess('If an account exists with this email, a reset link has been sent.');
|
||||||
|
} else {
|
||||||
|
setError('Unable to process request. Please try again.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error. Please check your connection.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPassword = async () => {
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
if (!token()) {
|
||||||
|
setError('Reset token is missing. Use the link from your email.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword().length < 8) {
|
||||||
|
setError('Password must be at least 8 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!passwordsMatch()) {
|
||||||
|
setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gateway/api/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: token(),
|
||||||
|
new_password: newPassword(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess('Password reset successfully. You can now login.');
|
||||||
|
setTimeout(() => navigate('/login', { replace: true }), 2000);
|
||||||
|
} else {
|
||||||
|
setError(String(data?.message || data?.error || 'Reset failed. Token may be expired.'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error. Please check your connection.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main class="auth-page">
|
||||||
|
<PublicBackground />
|
||||||
|
<PublicHeader />
|
||||||
|
|
||||||
|
<div class="auth-layout">
|
||||||
|
<section class="auth-visual card glass-dark">
|
||||||
|
<img class="auth-visual-img" src="/images/auth-company-1.jpg" alt="Reset Password" />
|
||||||
|
<div class="auth-visual-overlay" />
|
||||||
|
<div class="auth-visual-content">
|
||||||
|
<p class="eyebrow">Account Recovery</p>
|
||||||
|
<h1 class="title light">Reset Your Password</h1>
|
||||||
|
<p class="subtitle light">Securely reset your password with email verification.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="auth-form card glass-light">
|
||||||
|
<Show when={step() === 'request'} fallback={
|
||||||
|
<>
|
||||||
|
<h2 class="title">Set New Password</h2>
|
||||||
|
<p class="subtitle">Enter your new password below.</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="new-password">NEW PASSWORD</label>
|
||||||
|
<div class="auth-password-wrap">
|
||||||
|
<input id="new-password" type={showPassword() ? 'text' : 'password'} class="input" value={newPassword()} onInput={(e) => setNewPassword(e.currentTarget.value)} placeholder="Enter new password" />
|
||||||
|
<button class="auth-toggle-visibility" type="button" onClick={() => setShowPassword((p) => !p)} aria-label={showPassword() ? 'Hide password' : 'Show password'}>
|
||||||
|
<PasswordVisibilityIcon visible={showPassword()} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="validation-note" style={{ color: newPassword().length >= 8 ? '#fd6116' : '#6e7591' }}>
|
||||||
|
{newPassword().length >= 8 ? '✓ Meets minimum length' : '• Minimum 8 characters required'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="confirm-password">CONFIRM PASSWORD</label>
|
||||||
|
<div class="auth-password-wrap">
|
||||||
|
<input id="confirm-password" type={showConfirm() ? 'text' : 'password'} class="input" value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} placeholder="Confirm new password" />
|
||||||
|
<button class="auth-toggle-visibility" type="button" onClick={() => setShowConfirm((p) => !p)} aria-label={showConfirm() ? 'Hide password' : 'Show password'}>
|
||||||
|
<PasswordVisibilityIcon visible={showConfirm()} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="validation-note" style={{ color: passwordsMatch() ? '#fd6116' : '#6e7591' }}>
|
||||||
|
{confirmPassword() && passwordsMatch() ? '✓ Passwords match' : '• Passwords do not match'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="auth-submit-btn" type="button" onClick={() => void resetPassword()} disabled={submitting()}>
|
||||||
|
{submitting() ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="auth-footer-row">
|
||||||
|
<p class="note"><A href="/login">Back to Sign In</A></p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<h2 class="title">Forgot Password</h2>
|
||||||
|
<p class="subtitle">Enter your email to receive a password reset link.</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="reset-email">EMAIL ADDRESS</label>
|
||||||
|
<input id="reset-email" type="email" class="input" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} placeholder="Enter your registered email" />
|
||||||
|
<p class="validation-note" style={{ color: email().trim() && isValidEmail(email()) ? '#fd6116' : '#6e7591' }}>
|
||||||
|
{email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="auth-submit-btn" type="button" onClick={() => void requestReset()} disabled={submitting()}>
|
||||||
|
{submitting() ? 'Sending...' : 'Send Reset Link'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="auth-footer-row">
|
||||||
|
<p class="note"><A href="/login">Back to Sign In</A></p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="error">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={success()}>
|
||||||
|
<p class="error" style={{ color: '#16A34A' }}>{success()}</p>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { A, useNavigate } from '@solidjs/router';
|
import { A, useNavigate } from '@solidjs/router';
|
||||||
import { createMemo, createSignal, For, Show } from 'solid-js';
|
import { createMemo, createSignal, For, Show } from 'solid-js';
|
||||||
|
import { useAuth } from '~/lib/auth';
|
||||||
import PublicBackground from '~/components/PublicBackground';
|
import PublicBackground from '~/components/PublicBackground';
|
||||||
import PublicHeader from '~/components/PublicHeader';
|
import PublicHeader from '~/components/PublicHeader';
|
||||||
import CaptchaCanvas from '~/components/CaptchaCanvas';
|
import CaptchaCanvas from '~/components/CaptchaCanvas';
|
||||||
|
|
@ -33,6 +34,7 @@ function PasswordVisibilityIcon(props: { visible: boolean }) {
|
||||||
|
|
||||||
export default function LoginRoute() {
|
export default function LoginRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const auth = useAuth();
|
||||||
const [email, setEmail] = createSignal('');
|
const [email, setEmail] = createSignal('');
|
||||||
const [password, setPassword] = createSignal('');
|
const [password, setPassword] = createSignal('');
|
||||||
const [otp, setOtp] = createSignal(['', '', '', '', '', '']);
|
const [otp, setOtp] = createSignal(['', '', '', '', '', '']);
|
||||||
|
|
@ -91,10 +93,6 @@ export default function LoginRoute() {
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
if (email().trim().toLowerCase() === 'demo@nxtgauge.com' && password() === 'Demo@1234') {
|
|
||||||
dummyLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isValidEmail(email())) {
|
if (!isValidEmail(email())) {
|
||||||
setError('Enter a valid email address.');
|
setError('Enter a valid email address.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -139,6 +137,15 @@ export default function LoginRoute() {
|
||||||
window.localStorage.setItem('nxtgauge_access_token', accessToken);
|
window.localStorage.setItem('nxtgauge_access_token', accessToken);
|
||||||
}
|
}
|
||||||
saveUser(data?.user || {});
|
saveUser(data?.user || {});
|
||||||
|
if (auth.setUser) {
|
||||||
|
auth.setUser({
|
||||||
|
id: data?.user?.id || '',
|
||||||
|
email: data?.user?.email || email().trim().toLowerCase(),
|
||||||
|
full_name: data?.user?.full_name || '',
|
||||||
|
active_role: data?.user?.active_role || 'JOB_SEEKER',
|
||||||
|
email_verified: data?.user?.email_verified || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
@ -189,38 +196,6 @@ export default function LoginRoute() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dummyLogin = () => {
|
|
||||||
const demoUser = {
|
|
||||||
id: 'demo-user-001',
|
|
||||||
email: 'demo@nxtgauge.com',
|
|
||||||
full_name: 'Demo User',
|
|
||||||
email_verified: true,
|
|
||||||
active_role: 'JOB_SEEKER',
|
|
||||||
roles: ['JOB_SEEKER'],
|
|
||||||
};
|
|
||||||
const payload = {
|
|
||||||
firstName: 'Demo',
|
|
||||||
lastName: 'User',
|
|
||||||
fullName: 'Demo User',
|
|
||||||
name: 'Demo User',
|
|
||||||
displayName: 'Demo User',
|
|
||||||
email: 'demo@nxtgauge.com',
|
|
||||||
roleKey: 'job_seeker',
|
|
||||||
role: 'job_seeker',
|
|
||||||
user: demoUser,
|
|
||||||
};
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.sessionStorage.setItem('nxtgauge_access_token', 'dummy-access-token');
|
|
||||||
window.sessionStorage.setItem('nxtgauge_frontend_access_token', 'dummy-access-token');
|
|
||||||
window.localStorage.setItem('nxtgauge_access_token', 'dummy-access-token');
|
|
||||||
window.localStorage.setItem('nxtgauge_auth_user', JSON.stringify(payload));
|
|
||||||
window.localStorage.setItem('nxtgauge_user', JSON.stringify(payload));
|
|
||||||
window.localStorage.setItem('nxtgauge_signup_profile_v1', JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
setRoleGuess('job_seeker');
|
|
||||||
navigate('/dashboard', { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main class="auth-page">
|
<main class="auth-page">
|
||||||
<PublicBackground />
|
<PublicBackground />
|
||||||
|
|
@ -300,10 +275,6 @@ export default function LoginRoute() {
|
||||||
{submitting() ? 'Signing In...' : 'Sign In'}
|
{submitting() ? 'Signing In...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="auth-submit-btn" type="button" onClick={dummyLogin} disabled={submitting()} style={{ 'margin-top': '8px', background: '#111827' }}>
|
|
||||||
Dummy Login (Dashboard Test)
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Show when={showVerify()}>
|
<Show when={showVerify()}>
|
||||||
<button class="auth-submit-btn" type="button" onClick={() => void verifyThenLogin()} disabled={submitting()}>
|
<button class="auth-submit-btn" type="button" onClick={() => void verifyThenLogin()} disabled={submitting()}>
|
||||||
{submitting() ? 'Verifying...' : 'Verify Email and Login'}
|
{submitting() ? 'Verifying...' : 'Verify Email and Login'}
|
||||||
|
|
@ -313,6 +284,7 @@ export default function LoginRoute() {
|
||||||
<div class="auth-footer-row">
|
<div class="auth-footer-row">
|
||||||
<p class="footer-text">Secure login with email verification.</p>
|
<p class="footer-text">Secure login with email verification.</p>
|
||||||
<p class="note">New user? <A href="/signup">Sign Up</A></p>
|
<p class="note">New user? <A href="/signup">Sign Up</A></p>
|
||||||
|
<p class="note"><A href="/forgot-password">Forgot Password?</A></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue