Compare commits
No commits in common. "dc0b46fbd9513de64e4055c634e4834626e5047c" and "1ed1576101f976bdef996f61a6eee2b368dfa415" have entirely different histories.
dc0b46fbd9
...
1ed1576101
9
.gitignore
vendored
9
.gitignore
vendored
@ -26,12 +26,3 @@ dist-ssr
|
|||||||
# vite-plugin-pwa dev output
|
# vite-plugin-pwa dev output
|
||||||
dev-dist
|
dev-dist
|
||||||
.agents/
|
.agents/
|
||||||
|
|
||||||
# Testing / E2E / API tests
|
|
||||||
.env.e2e*
|
|
||||||
e2e/
|
|
||||||
api-tests/
|
|
||||||
playwright-report/
|
|
||||||
test-results/
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ This is a Progressive Web App (Pwa) built for the Krishna Sales team.
|
|||||||
- Logout now calls `POST /usr/logout` to revoke the session and cookie on the server (retried if the call fails). Sign-out and sign-in in one tab are reflected in the others; a different user signing in elsewhere reloads the tab.
|
- Logout now calls `POST /usr/logout` to revoke the session and cookie on the server (retried if the call fails). Sign-out and sign-in in one tab are reflected in the others; a different user signing in elsewhere reloads the tab.
|
||||||
- One shared session for all four workflow clients (`src/api/session.ts`) replaces the per-client token copies. Existing sign-ins are cleared once, so everyone logs in again after this update.
|
- One shared session for all four workflow clients (`src/api/session.ts`) replaces the per-client token copies. Existing sign-ins are cleared once, so everyone logs in again after this update.
|
||||||
- Password policy: live rules checklist, forced change on login when the password is expired, temporary or predates the policy, forgot / reset password, and change password.
|
- Password policy: live rules checklist, forced change on login when the password is expired, temporary or predates the policy, forgot / reset password, and change password.
|
||||||
- Password expiry warning & enforcement: Evaluates `user.password_status` (`warn`, `days_left`, `must_change`). Shows a top banner in `ConsoleLayout` with "Change Password" and "Later" skip options during the warning window; automatically launches the mandatory password change dialog when the password has expired.
|
- "Password change due" banner when the password is about to expire (from `user.password_status` at login), with a **Change now** shortcut.
|
||||||
- Note: the refresh cookie is only set when the app and the API share one origin (`VITE_ZINO_API_URL` = `SAME_ORIGIN`, or the app's own host). On a different origin the server returns the token but no cookie, and the session ends when the access token expires.
|
- Note: the refresh cookie is only set when the app and the API share one origin (`VITE_ZINO_API_URL` = `SAME_ORIGIN`, or the app's own host). On a different origin the server returns the token but no cookie, and the session ends when the access token expires.
|
||||||
|
|
||||||
**Maintenance**
|
**Maintenance**
|
||||||
|
|||||||
@ -19,9 +19,4 @@ export default defineConfig([
|
|||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Playwright fixtures take a `use` callback that the React hooks rule mistakes for a hook.
|
|
||||||
files: ['e2e/**/*.ts'],
|
|
||||||
rules: { 'react-hooks/rules-of-hooks': 'off' },
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
|
|||||||
67
package-lock.json
generated
67
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "krishna-sales-pwa",
|
"name": "krishna-sales-pwa",
|
||||||
"version": "1.0.4",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "krishna-sales-pwa",
|
"name": "krishna-sales-pwa",
|
||||||
"version": "1.0.4",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-google-maps/api": "^2.20.8",
|
"@react-google-maps/api": "^2.20.8",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
@ -20,14 +20,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.65.0",
|
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"dotenv": "^18.0.9",
|
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
@ -2311,22 +2309,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
|
||||||
"version": "1.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz",
|
|
||||||
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "1.65.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@quansync/fs": {
|
"node_modules/@quansync/fs": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
||||||
@ -4734,22 +4716,6 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
|
||||||
"version": "18.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz",
|
|
||||||
"integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"bin": {
|
|
||||||
"dotenv": "dist/index.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -7141,35 +7107,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
|
|
||||||
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.65.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
|
|
||||||
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@ -8,13 +8,7 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"generate-pwa-assets": "pwa-assets-generator",
|
"generate-pwa-assets": "pwa-assets-generator"
|
||||||
"e2e": "playwright test -c e2e/playwright.config.ts",
|
|
||||||
"e2e:mocked": "playwright test -c e2e/playwright.config.ts --project=mocked",
|
|
||||||
"e2e:live": "playwright test -c e2e/playwright.config.ts --project=setup --project=live",
|
|
||||||
"e2e:report": "playwright show-report e2e/playwright-report",
|
|
||||||
"e2e:typecheck": "tsc -p e2e --noEmit",
|
|
||||||
"api:test": "node api-tests/run.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-google-maps/api": "^2.20.8",
|
"@react-google-maps/api": "^2.20.8",
|
||||||
@ -29,14 +23,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.65.0",
|
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"dotenv": "^18.0.9",
|
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export async function resetPassword(resetToken: string, newPassword: string): Pr
|
|||||||
* On 200 success, the backend bumps password_changed_at and invalidates the session JWT.
|
* On 200 success, the backend bumps password_changed_at and invalidates the session JWT.
|
||||||
*/
|
*/
|
||||||
export async function changePassword(
|
export async function changePassword(
|
||||||
currentPassword: string | undefined | null,
|
currentPassword: string,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
token?: string | null
|
token?: string | null
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
@ -97,13 +97,6 @@ export async function changePassword(
|
|||||||
throw new AuthApiError('You must be signed in to change your password', 401);
|
throw new AuthApiError('You must be signed in to change your password', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: Record<string, string> = {
|
|
||||||
new_password: newPassword,
|
|
||||||
};
|
|
||||||
if (currentPassword) {
|
|
||||||
payload.current_password = currentPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/usr/me/password`, {
|
const res = await fetch(`${BASE_URL}/usr/me/password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: AUTH_CREDENTIALS,
|
credentials: AUTH_CREDENTIALS,
|
||||||
@ -111,7 +104,10 @@ export async function changePassword(
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${authToken}`,
|
Authorization: `Bearer ${authToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
|
|||||||
@ -36,7 +36,6 @@ export async function loginAll(email: string, password: string, orgId?: string)
|
|||||||
try {
|
try {
|
||||||
const me = await orderBookingClient.getMe();
|
const me = await orderBookingClient.getMe();
|
||||||
res.user = { ...res.user, ...me };
|
res.user = { ...res.user, ...me };
|
||||||
ALL.forEach((c) => c.setUser(res.user));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch user profile:', e);
|
console.error('Failed to fetch user profile:', e);
|
||||||
}
|
}
|
||||||
@ -56,7 +55,6 @@ export async function completePasswordChangeAll(
|
|||||||
try {
|
try {
|
||||||
const me = await orderBookingClient.getMe();
|
const me = await orderBookingClient.getMe();
|
||||||
res.user = { ...res.user, ...me };
|
res.user = { ...res.user, ...me };
|
||||||
ALL.forEach((c) => c.setUser(res.user));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch user profile:', e);
|
console.error('Failed to fetch user profile:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,13 +10,6 @@ export interface ApiError {
|
|||||||
data?: any;
|
data?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordStatus {
|
|
||||||
must_change?: boolean;
|
|
||||||
expires_at?: string;
|
|
||||||
days_left?: number;
|
|
||||||
warn?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
org_id: string;
|
org_id: string;
|
||||||
@ -25,8 +18,6 @@ export interface User {
|
|||||||
roles: string[];
|
roles: string[];
|
||||||
groups: string[];
|
groups: string[];
|
||||||
is_admin?: boolean;
|
is_admin?: boolean;
|
||||||
provider?: string;
|
|
||||||
password_status?: PasswordStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState, type ReactNode, useEffect } from 'react';
|
import { useState, type ReactNode, useEffect } from 'react';
|
||||||
import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients';
|
import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients';
|
||||||
import { AuthCtx, type AuthValue } from './context';
|
import { AuthCtx, type AuthValue, type PasswordStatus } from './context';
|
||||||
import type { PasswordStatus } from '../api/types';
|
|
||||||
import { sessionClient } from '../api/session';
|
import { sessionClient } from '../api/session';
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
@ -18,7 +17,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [passwordStatus, setPasswordStatus] = useState<PasswordStatus | null>(() => {
|
const [passwordStatus, setPasswordStatus] = useState<PasswordStatus | null>(() => {
|
||||||
return orderBookingClient.currentUser()?.password_status ?? null;
|
try {
|
||||||
|
const raw = localStorage.getItem('krishna_sales_mobile_password_status');
|
||||||
|
return raw ? (JSON.parse(raw) as PasswordStatus) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep React in step with the session: refresh/idle expiry, a sign-out or a
|
// Keep React in step with the session: refresh/idle expiry, a sign-out or a
|
||||||
@ -44,11 +48,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authed && (!isAdmin || roles.length === 0 || !passwordStatus)) {
|
if (authed && (!isAdmin || roles.length === 0)) {
|
||||||
orderBookingClient.getMe().then(me => {
|
orderBookingClient.getMe().then(me => {
|
||||||
if (me.is_admin) setIsAdmin(true);
|
if (me.is_admin) setIsAdmin(true);
|
||||||
if (me.roles) setRoles(me.roles);
|
if (me.roles) setRoles(me.roles);
|
||||||
if (me.password_status) setPasswordStatus(me.password_status);
|
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
}, [authed]);
|
}, [authed]);
|
||||||
@ -65,11 +68,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false');
|
localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false');
|
||||||
setRoles(userRoles);
|
setRoles(userRoles);
|
||||||
localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles));
|
localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles));
|
||||||
if (res.user?.password_status) {
|
// Only a due/expiring password is worth remembering; a fresh login replaces it.
|
||||||
setPasswordStatus(res.user.password_status);
|
const status: PasswordStatus | null = res.user?.password_status?.warn ? res.user.password_status : null;
|
||||||
} else {
|
setPasswordStatus(status);
|
||||||
setPasswordStatus(null);
|
if (status) localStorage.setItem('krishna_sales_mobile_password_status', JSON.stringify(status));
|
||||||
}
|
else localStorage.removeItem('krishna_sales_mobile_password_status');
|
||||||
};
|
};
|
||||||
|
|
||||||
const value: AuthValue = {
|
const value: AuthValue = {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import type { PasswordStatus } from '../api/types';
|
|
||||||
|
|
||||||
export interface PasswordChangeRequiredInfo {
|
export interface PasswordChangeRequiredInfo {
|
||||||
message: string;
|
message: string;
|
||||||
@ -7,12 +6,19 @@ export interface PasswordChangeRequiredInfo {
|
|||||||
rules?: string[];
|
rules?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `user.password_status` from /usr/login: set when a password change is coming due. */
|
||||||
|
export interface PasswordStatus {
|
||||||
|
warn?: boolean;
|
||||||
|
days_left?: number;
|
||||||
|
change_required_from?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthValue {
|
export interface AuthValue {
|
||||||
authed: boolean;
|
authed: boolean;
|
||||||
|
passwordStatus: PasswordStatus | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
passwordStatus: PasswordStatus | null;
|
|
||||||
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
||||||
completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise<void>;
|
completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Card } from '../reusable';
|
|
||||||
import logo from '../../assets/logo.png';
|
|
||||||
|
|
||||||
/** Shared auth-page chrome: curved green background, brand header and a card. */
|
|
||||||
export function AuthShell({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="relative min-h-screen overflow-hidden flex flex-col items-center justify-center gap-6 p-4 bg-app">
|
|
||||||
{/* Decorative curves */}
|
|
||||||
<svg
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute top-0 left-0 w-[70%] max-w-[520px] h-auto"
|
|
||||||
viewBox="0 0 400 220"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="wave-top" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stopColor="#7fd49a" stopOpacity="0.85" />
|
|
||||||
<stop offset="1" stopColor="#d9f3e2" stopOpacity="0.2" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d="M0 0H400C340 40 300 120 190 150C110 172 50 150 0 210Z" fill="url(#wave-top)" />
|
|
||||||
<path d="M0 0H250C200 30 150 70 90 90C50 103 20 100 0 130Z" fill="#9adcae" fillOpacity="0.55" />
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute bottom-0 left-0 w-full h-auto"
|
|
||||||
viewBox="0 0 400 180"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="wave-bottom" x1="0" y1="0" x2="1" y2="0">
|
|
||||||
<stop offset="0" stopColor="#d9f3e2" stopOpacity="0.4" />
|
|
||||||
<stop offset="1" stopColor="#6cc88b" stopOpacity="0.8" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d="M0 80C80 30 160 40 240 80C310 115 360 90 400 50V180H0Z" fill="url(#wave-bottom)" />
|
|
||||||
<path d="M0 130C90 100 170 110 250 135C320 157 370 140 400 115V180H0Z" fill="#4fb374" fillOpacity="0.55" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="relative flex flex-col gap-2 items-center text-center">
|
|
||||||
<img src={logo} alt="Krishna Sales Logo" className="h-20 w-20 rounded-full object-contain shadow-md" />
|
|
||||||
<h1 className="m-0 text-3xl font-extrabold text-emerald-900 tracking-[-0.02em]">Krishna Sales</h1>
|
|
||||||
</div>
|
|
||||||
<Card className="relative w-full max-w-[420px] rounded-3xl shadow-xl shadow-emerald-900/10 border-white/60 bg-white/85 backdrop-blur p-6 sm:p-8">
|
|
||||||
<div className="mb-5">
|
|
||||||
<h2 className="m-0 text-2xl font-extrabold text-emerald-950">{title}</h2>
|
|
||||||
{subtitle && <p className="text-sm text-slate-600 mt-1 leading-relaxed">{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@ import { Button } from '../buttons/Button';
|
|||||||
import { PasswordRulesChecklist } from './PasswordRulesChecklist';
|
import { PasswordRulesChecklist } from './PasswordRulesChecklist';
|
||||||
import { changePassword } from '../../api/auth';
|
import { changePassword } from '../../api/auth';
|
||||||
import { usePasswordPolicy } from '../../hooks/usePasswordPolicy';
|
import { usePasswordPolicy } from '../../hooks/usePasswordPolicy';
|
||||||
import { cn } from '../../lib/cn';
|
|
||||||
import {
|
import {
|
||||||
passwordMeetsPolicy,
|
passwordMeetsPolicy,
|
||||||
passwordTooLong,
|
passwordTooLong,
|
||||||
@ -18,7 +17,6 @@ interface ChangePasswordModalProps {
|
|||||||
userEmail?: string | null;
|
userEmail?: string | null;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
onSuccessLogout: () => void;
|
onSuccessLogout: () => void;
|
||||||
required?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChangePasswordModal({
|
export function ChangePasswordModal({
|
||||||
@ -27,7 +25,6 @@ export function ChangePasswordModal({
|
|||||||
userEmail,
|
userEmail,
|
||||||
userName,
|
userName,
|
||||||
onSuccessLogout,
|
onSuccessLogout,
|
||||||
required = false,
|
|
||||||
}: ChangePasswordModalProps) {
|
}: ChangePasswordModalProps) {
|
||||||
const policy = usePasswordPolicy(open);
|
const policy = usePasswordPolicy(open);
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
@ -37,12 +34,6 @@ export function ChangePasswordModal({
|
|||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<{
|
|
||||||
current?: boolean;
|
|
||||||
new?: boolean;
|
|
||||||
confirm?: boolean;
|
|
||||||
}>({});
|
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
@ -52,7 +43,6 @@ export function ChangePasswordModal({
|
|||||||
setCurrentPassword('');
|
setCurrentPassword('');
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
setFieldErrors({});
|
|
||||||
setError(null);
|
setError(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@ -60,61 +50,35 @@ export function ChangePasswordModal({
|
|||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
const errors: { current?: boolean; new?: boolean; confirm?: boolean } = {};
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
setError('All fields are required.');
|
||||||
if (!required && !currentPassword) {
|
|
||||||
errors.current = true;
|
|
||||||
}
|
|
||||||
if (!newPassword) {
|
|
||||||
errors.new = true;
|
|
||||||
}
|
|
||||||
if (!confirmPassword) {
|
|
||||||
errors.confirm = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
|
||||||
setFieldErrors(errors);
|
|
||||||
if (!required && errors.current) {
|
|
||||||
setError('Current password is required.');
|
|
||||||
} else if (errors.new && errors.confirm) {
|
|
||||||
setError('Please enter and confirm your new password.');
|
|
||||||
} else if (errors.new) {
|
|
||||||
setError('New password is required.');
|
|
||||||
} else {
|
|
||||||
setError('Please confirm your new password.');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwordMeetsPolicy(policy, newPassword, { email: userEmail, name: userName })) {
|
if (!passwordMeetsPolicy(policy, newPassword, { email: userEmail, name: userName })) {
|
||||||
setFieldErrors({ new: true });
|
|
||||||
setError('New password does not meet all of the password requirements.');
|
setError('New password does not meet all of the password requirements.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordTooLong(newPassword)) {
|
if (passwordTooLong(newPassword)) {
|
||||||
setFieldErrors({ new: true });
|
|
||||||
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
|
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setFieldErrors({ confirm: true });
|
|
||||||
setError('New passwords do not match.');
|
setError('New passwords do not match.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!required && currentPassword && newPassword === currentPassword) {
|
if (newPassword === currentPassword) {
|
||||||
setFieldErrors({ new: true });
|
|
||||||
setError('New password must be different from your current password.');
|
setError('New password must be different from your current password.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await changePassword(required ? undefined : currentPassword, newPassword);
|
await changePassword(currentPassword, newPassword);
|
||||||
setDone(true);
|
setDone(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onSuccessLogout();
|
onSuccessLogout();
|
||||||
@ -130,58 +94,43 @@ export function ChangePasswordModal({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
closable={!required}
|
|
||||||
width="sm"
|
width="sm"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Lock className="text-emerald-600 size-5" />
|
<Lock className="text-emerald-600 size-5" />
|
||||||
<span>{required ? 'Password Expired' : 'Change Password'}</span>
|
<span>Change Password</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
subtitle={
|
subtitle="Update your account password. You'll need to sign in again after."
|
||||||
required
|
|
||||||
? 'Your password has expired. Please set a new password to continue.'
|
|
||||||
: "Update your account password. You'll need to sign in again after."
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<form noValidate onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
{/* Current Password — only required for self-service change; hidden when must_change is active */}
|
{/* Current Password */}
|
||||||
{!required && (
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex flex-col gap-1.5">
|
<label className="text-xs font-semibold text-slate-700">
|
||||||
<label className="text-xs font-semibold text-slate-700">
|
Current password <span className="text-rose-500">*</span>
|
||||||
Current password <span className="text-rose-500">*</span>
|
</label>
|
||||||
</label>
|
<div className="relative flex items-center">
|
||||||
<div className="relative flex items-center">
|
<input
|
||||||
<input
|
type={showCurrent ? 'text' : 'password'}
|
||||||
type={showCurrent ? 'text' : 'password'}
|
autoComplete="current-password"
|
||||||
autoComplete="current-password"
|
value={currentPassword}
|
||||||
value={currentPassword}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
onChange={(e) => {
|
disabled={submitting || done}
|
||||||
setCurrentPassword(e.target.value);
|
required
|
||||||
if (fieldErrors.current) setFieldErrors((prev) => ({ ...prev, current: false }));
|
className="h-[42px] w-full px-3 pr-10 text-sm border border-slate-200 bg-slate-50/50 rounded-lg outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all text-slate-800"
|
||||||
setError(null);
|
placeholder="Enter current password"
|
||||||
}}
|
/>
|
||||||
disabled={submitting || done}
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all',
|
onClick={() => setShowCurrent((s) => !s)}
|
||||||
fieldErrors.current
|
disabled={submitting || done}
|
||||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500'
|
tabIndex={-1}
|
||||||
: 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500'
|
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||||
)}
|
>
|
||||||
placeholder="Enter current password"
|
{showCurrent ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
/>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCurrent((s) => !s)}
|
|
||||||
disabled={submitting || done}
|
|
||||||
tabIndex={-1}
|
|
||||||
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
{showCurrent ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@ -193,19 +142,10 @@ export function ChangePasswordModal({
|
|||||||
type={showNew ? 'text' : 'password'}
|
type={showNew ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => {
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
setNewPassword(e.target.value);
|
|
||||||
if (fieldErrors.new) setFieldErrors((prev) => ({ ...prev, new: false }));
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
disabled={submitting || done}
|
disabled={submitting || done}
|
||||||
autoFocus={required}
|
required
|
||||||
className={cn(
|
className="h-[42px] w-full px-3 pr-10 text-sm border border-slate-200 bg-slate-50/50 rounded-lg outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all text-slate-800"
|
||||||
'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all',
|
|
||||||
fieldErrors.new
|
|
||||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500'
|
|
||||||
: 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500'
|
|
||||||
)}
|
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -237,18 +177,10 @@ export function ChangePasswordModal({
|
|||||||
type={showConfirm ? 'text' : 'password'}
|
type={showConfirm ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => {
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
setConfirmPassword(e.target.value);
|
|
||||||
if (fieldErrors.confirm) setFieldErrors((prev) => ({ ...prev, confirm: false }));
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
disabled={submitting || done}
|
disabled={submitting || done}
|
||||||
className={cn(
|
required
|
||||||
'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all',
|
className="h-[42px] w-full px-3 pr-10 text-sm border border-slate-200 bg-slate-50/50 rounded-lg outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all text-slate-800"
|
||||||
fieldErrors.confirm
|
|
||||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500'
|
|
||||||
: 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500'
|
|
||||||
)}
|
|
||||||
placeholder="Re-enter new password"
|
placeholder="Re-enter new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -278,25 +210,14 @@ export function ChangePasswordModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2.5 mt-2">
|
<div className="flex items-center justify-end gap-2.5 mt-2">
|
||||||
{required ? (
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="outline"
|
disabled={submitting || done}
|
||||||
disabled={submitting || done}
|
onClick={handleClose}
|
||||||
onClick={onSuccessLogout}
|
>
|
||||||
>
|
Cancel
|
||||||
Sign out
|
</Button>
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={submitting || done}
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || done}
|
disabled={submitting || done}
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { AlertTriangle, KeyRound, Lock } from 'lucide-react';
|
|
||||||
import type { PasswordStatus } from '../../api/types';
|
|
||||||
|
|
||||||
interface PasswordExpiryWarningBannerProps {
|
|
||||||
passwordStatus?: PasswordStatus | null;
|
|
||||||
onChangePassword: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PasswordExpiryWarningBanner({
|
|
||||||
passwordStatus,
|
|
||||||
onChangePassword,
|
|
||||||
}: PasswordExpiryWarningBannerProps) {
|
|
||||||
const expiresAt = passwordStatus?.expires_at || 'session';
|
|
||||||
const dismissKey = `krishna_sales_mobile_dismissed_pw_warn_${expiresAt}`;
|
|
||||||
|
|
||||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
|
||||||
try {
|
|
||||||
return sessionStorage.getItem(dismissKey) === 'true';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const isExpired = Boolean(
|
|
||||||
passwordStatus?.must_change ||
|
|
||||||
(passwordStatus?.days_left !== undefined && passwordStatus.days_left <= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Evaluate warning state: backend flags warn: true, or days_left is within warning window (<= 3)
|
|
||||||
const isWarnActive = Boolean(
|
|
||||||
isExpired ||
|
|
||||||
passwordStatus?.warn ||
|
|
||||||
(passwordStatus?.days_left !== undefined && passwordStatus.days_left <= 3)
|
|
||||||
);
|
|
||||||
|
|
||||||
// If not warning/expired, or if dismissed while NOT expired, hide banner.
|
|
||||||
// When expired, dismissal is disabled so the user must take action.
|
|
||||||
if (!isWarnActive || (dismissed && !isExpired)) return null;
|
|
||||||
|
|
||||||
const daysLeft = passwordStatus?.days_left;
|
|
||||||
let headline = 'Your password will expire soon.';
|
|
||||||
if (isExpired) {
|
|
||||||
headline = 'Your password has expired.';
|
|
||||||
} else if (daysLeft !== undefined) {
|
|
||||||
if (daysLeft === 1) {
|
|
||||||
headline = 'Your password will expire tomorrow.';
|
|
||||||
} else {
|
|
||||||
headline = `Your password will expire in ${daysLeft} days.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDismiss = () => {
|
|
||||||
setDismissed(true);
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(dismissKey, 'true');
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isExpired) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="print:hidden shrink-0 px-3.5 py-2.5 sm:px-4 sm:py-2.5 bg-rose-50 border-b border-rose-200 text-xs text-rose-900 font-sans shadow-xs transition-all"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 sm:gap-4">
|
|
||||||
<div className="flex items-start sm:items-center gap-2.5 min-w-0 flex-1">
|
|
||||||
<Lock size={18} className="text-rose-600 shrink-0 mt-0.5 sm:mt-0" />
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-x-2 gap-y-0.5 min-w-0">
|
|
||||||
<strong className="font-semibold text-rose-950 text-xs sm:text-sm">{headline}</strong>
|
|
||||||
<span className="text-rose-800 text-2xs sm:text-xs">
|
|
||||||
You must update your password to continue using your account.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center ml-7 sm:ml-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onChangePassword}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-semibold rounded-md shadow-xs transition-colors cursor-pointer text-xs"
|
|
||||||
>
|
|
||||||
<KeyRound size={13} />
|
|
||||||
<span>Change Password Now</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="print:hidden shrink-0 px-3.5 py-2.5 sm:px-4 sm:py-2.5 bg-amber-50 border-b border-amber-200 text-xs text-amber-900 font-sans shadow-xs transition-all"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 sm:gap-4">
|
|
||||||
<div className="flex items-start sm:items-center gap-2.5 min-w-0 flex-1">
|
|
||||||
<AlertTriangle size={18} className="text-amber-600 shrink-0 mt-0.5 sm:mt-0" />
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-x-2 gap-y-0.5 min-w-0">
|
|
||||||
<strong className="font-semibold text-amber-950 text-xs sm:text-sm">{headline}</strong>
|
|
||||||
<span className="text-amber-800 text-2xs sm:text-xs">
|
|
||||||
Please update your password to maintain access to your account.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center ml-7 sm:ml-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleDismiss}
|
|
||||||
className="px-2.5 py-1.5 text-xs font-semibold text-amber-800 hover:text-amber-950 hover:bg-amber-100/80 active:bg-amber-200/80 rounded-md transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Later
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onChangePassword}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 hover:bg-amber-700 active:bg-amber-800 text-white font-semibold rounded-md shadow-xs transition-colors cursor-pointer text-xs"
|
|
||||||
>
|
|
||||||
<KeyRound size={13} />
|
|
||||||
<span>Change Password</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -22,24 +22,12 @@ export interface ModalProps {
|
|||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
/** Whether the modal can be dismissed via Esc, backdrop, or close button. @default true */
|
|
||||||
closable?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
|
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
|
||||||
export function Modal({
|
export function Modal({ open, onClose, title, subtitle, width = 'md', maxHeight = 'max-h-[80dvh]', actions, children }: ModalProps) {
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
width = 'md',
|
|
||||||
maxHeight = 'max-h-[80dvh]',
|
|
||||||
actions,
|
|
||||||
children,
|
|
||||||
closable = true,
|
|
||||||
}: ModalProps) {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !closable) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onClose();
|
||||||
};
|
};
|
||||||
@ -50,7 +38,7 @@ export function Modal({
|
|||||||
document.removeEventListener('keydown', onKey);
|
document.removeEventListener('keydown', onKey);
|
||||||
document.body.style.overflow = prev;
|
document.body.style.overflow = prev;
|
||||||
};
|
};
|
||||||
}, [open, onClose, closable]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@ -58,7 +46,7 @@ export function Modal({
|
|||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-end sm:items-center justify-center overflow-y-auto bg-black/40 sm:p-8"
|
className="fixed inset-0 z-[10000] flex items-end sm:items-center justify-center overflow-y-auto bg-black/40 sm:p-8"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (closable && e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -81,15 +69,13 @@ export function Modal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
|
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
|
||||||
{closable && (
|
<button
|
||||||
<button
|
onClick={onClose}
|
||||||
onClick={onClose}
|
aria-label="Close"
|
||||||
aria-label="Close"
|
className="shrink-0 -mr-1 p-1.5 rounded-md text-slate-800 hover:text-black hover:bg-slate-200 transition-colors"
|
||||||
className="shrink-0 -mr-1 p-1.5 rounded-md text-slate-800 hover:text-black hover:bg-slate-200 transition-colors cursor-pointer"
|
>
|
||||||
>
|
<X size={22} strokeWidth={3} />
|
||||||
<X size={22} strokeWidth={3} />
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="shrink min-h-0 overflow-y-auto scrollbar-slim">
|
<div className="shrink min-h-0 overflow-y-auto scrollbar-slim">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { LogOut, Menu, X, Database, Users, KeyRound } from 'lucide-react';
|
import { LogOut, Menu, X, Database, Users, KeyRound, AlertTriangle } from 'lucide-react';
|
||||||
import { cn } from '../lib/cn';
|
import { cn } from '../lib/cn';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||||
@ -8,7 +8,6 @@ import { SCREENS } from './tabs';
|
|||||||
import { routeConfig } from '../routesConfig';
|
import { routeConfig } from '../routesConfig';
|
||||||
import { APP_VERSION } from '../api/config';
|
import { APP_VERSION } from '../api/config';
|
||||||
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
|
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
|
||||||
import { PasswordExpiryWarningBanner } from '../components/auth/PasswordExpiryWarningBanner';
|
|
||||||
import { NoticeStrips } from '../components/common/NoticeBanner';
|
import { NoticeStrips } from '../components/common/NoticeBanner';
|
||||||
import logoUrl from '../assets/logo.png';
|
import logoUrl from '../assets/logo.png';
|
||||||
import './screen.css';
|
import './screen.css';
|
||||||
@ -26,25 +25,13 @@ const canAccessScreen = (key: string, isAdmin: boolean, roles: string[]) => {
|
|||||||
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
|
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
|
||||||
export function ConsoleLayout() {
|
export function ConsoleLayout() {
|
||||||
const { authed, logout, isAdmin, roles: userRoles, userEmail, passwordStatus } = useAuth();
|
const { authed, logout, isAdmin, roles: userRoles, userEmail, passwordStatus } = useAuth();
|
||||||
|
const [passwordDueDismissed, setPasswordDueDismissed] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = orderBookingClient.currentUser();
|
const user = orderBookingClient.currentUser();
|
||||||
const effectivePasswordStatus = passwordStatus ?? user?.password_status;
|
|
||||||
|
|
||||||
const isExpired = Boolean(
|
|
||||||
effectivePasswordStatus?.must_change ||
|
|
||||||
(effectivePasswordStatus?.days_left !== undefined && effectivePasswordStatus.days_left <= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||||
|
|
||||||
// If password has expired, automatically move the user directly to change password
|
|
||||||
useEffect(() => {
|
|
||||||
if (isExpired) {
|
|
||||||
setIsChangePasswordOpen(true);
|
|
||||||
}
|
|
||||||
}, [isExpired]);
|
|
||||||
|
|
||||||
// A 401 from any workflow client bounces back to login.
|
// A 401 from any workflow client bounces back to login.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onAuthErrorAll(() => {
|
onAuthErrorAll(() => {
|
||||||
@ -75,10 +62,38 @@ export function ConsoleLayout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<NoticeStrips />
|
<NoticeStrips />
|
||||||
<PasswordExpiryWarningBanner
|
|
||||||
passwordStatus={effectivePasswordStatus}
|
{passwordStatus?.warn && !passwordDueDismissed && (
|
||||||
onChangePassword={() => setIsChangePasswordOpen(true)}
|
<div
|
||||||
/>
|
role="alert"
|
||||||
|
className="print:hidden shrink-0 flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-900"
|
||||||
|
>
|
||||||
|
<AlertTriangle size={16} className="shrink-0 text-amber-700" />
|
||||||
|
<span className="flex-1">
|
||||||
|
<strong>Password change due.</strong>{' '}
|
||||||
|
{typeof passwordStatus.days_left === 'number'
|
||||||
|
? passwordStatus.days_left <= 0
|
||||||
|
? 'Your password expires today.'
|
||||||
|
: `Your password expires in ${passwordStatus.days_left} day${passwordStatus.days_left === 1 ? '' : 's'}.`
|
||||||
|
: 'Please update it soon.'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsChangePasswordOpen(true)}
|
||||||
|
className="font-semibold text-amber-900 underline cursor-pointer"
|
||||||
|
>
|
||||||
|
Change now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onClick={() => setPasswordDueDismissed(true)}
|
||||||
|
className="text-amber-700 hover:text-amber-900 cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto px-4 pt-4 pb-24">
|
<main className="flex-1 overflow-y-auto px-4 pt-4 pb-24">
|
||||||
<div className="w-full max-w-[1200px] mx-auto flex flex-col gap-5">
|
<div className="w-full max-w-[1200px] mx-auto flex flex-col gap-5">
|
||||||
@ -246,12 +261,7 @@ export function ConsoleLayout() {
|
|||||||
|
|
||||||
<ChangePasswordModal
|
<ChangePasswordModal
|
||||||
open={isChangePasswordOpen}
|
open={isChangePasswordOpen}
|
||||||
onClose={() => {
|
onClose={() => setIsChangePasswordOpen(false)}
|
||||||
if (!isExpired) {
|
|
||||||
setIsChangePasswordOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
required={isExpired}
|
|
||||||
userEmail={userEmail || user?.email}
|
userEmail={userEmail || user?.email}
|
||||||
userName={user?.name}
|
userName={user?.name}
|
||||||
onSuccessLogout={() => {
|
onSuccessLogout={() => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, type FormEvent } from 'react';
|
import { useState, useEffect, type FormEvent } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { Eye, EyeOff, ArrowLeft, Mail, Lock, KeyRound, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { Eye, EyeOff, ArrowLeft, Mail, KeyRound, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { AuthShell } from '../components/auth/AuthShell';
|
import { Card } from '../components/reusable';
|
||||||
import { Button } from '../components/buttons';
|
import { Button } from '../components/buttons';
|
||||||
import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist';
|
import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist';
|
||||||
import { forgotPassword, verifyResetCode, resetPassword } from '../api/auth';
|
import { forgotPassword, verifyResetCode, resetPassword } from '../api/auth';
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
passwordTooLong,
|
passwordTooLong,
|
||||||
PASSWORD_MAX_CHARS,
|
PASSWORD_MAX_CHARS,
|
||||||
} from '../utils/passwordPolicy';
|
} from '../utils/passwordPolicy';
|
||||||
|
import logo from '../assets/logo.png';
|
||||||
|
|
||||||
const CODE_LENGTH = 6;
|
const CODE_LENGTH = 6;
|
||||||
const RESEND_COOLDOWN = 60; // seconds
|
const RESEND_COOLDOWN = 60; // seconds
|
||||||
@ -33,13 +34,6 @@ export function ForgotPasswordPage() {
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
const policy = usePasswordPolicy(step === 'password');
|
const policy = usePasswordPolicy(step === 'password');
|
||||||
const codeInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// The email and code inputs share a tree position, so React reuses the DOM node
|
|
||||||
// and autoFocus doesn't fire on step change — focus the code input explicitly.
|
|
||||||
useEffect(() => {
|
|
||||||
if (step === 'code') codeInputRef.current?.focus();
|
|
||||||
}, [step]);
|
|
||||||
|
|
||||||
// Cooldown countdown
|
// Cooldown countdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -143,23 +137,31 @@ export function ForgotPasswordPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = done
|
|
||||||
? 'Password Reset Complete'
|
|
||||||
: step === 'email'
|
|
||||||
? 'Forgot Password'
|
|
||||||
: step === 'code'
|
|
||||||
? 'Enter Verification Code'
|
|
||||||
: 'Create New Password';
|
|
||||||
const subtitle = done
|
|
||||||
? 'Your password has been changed. Redirecting to login…'
|
|
||||||
: step === 'email'
|
|
||||||
? "Enter your email address and we'll send you a 6-digit verification code."
|
|
||||||
: step === 'code'
|
|
||||||
? `We emailed a ${CODE_LENGTH}-digit code to ${email}. It is valid for 10 minutes.`
|
|
||||||
: 'Choose a strong new password that meets the security policy.';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell title={title} subtitle={subtitle}>
|
<div className="min-h-screen flex items-center justify-center p-4 bg-emerald-50">
|
||||||
|
<Card className="w-full max-w-[420px] shadow-lg border-emerald-100 bg-white p-6 sm:p-7">
|
||||||
|
<div className="flex flex-col gap-2 items-center mb-6 text-center">
|
||||||
|
<img src={logo} alt="Krishna Sales Logo" className="h-14 w-auto object-contain" />
|
||||||
|
<h1 className="text-xl font-extrabold text-emerald-950 tracking-tight">
|
||||||
|
{done
|
||||||
|
? 'Password Reset Complete'
|
||||||
|
: step === 'email'
|
||||||
|
? 'Forgot Password'
|
||||||
|
: step === 'code'
|
||||||
|
? 'Enter Verification Code'
|
||||||
|
: 'Create New Password'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500 max-w-[340px] leading-relaxed">
|
||||||
|
{done
|
||||||
|
? 'Your password has been changed. Redirecting to login…'
|
||||||
|
: step === 'email'
|
||||||
|
? "Enter your email address and we'll send you a 6-digit verification code."
|
||||||
|
: step === 'code'
|
||||||
|
? `We emailed a ${CODE_LENGTH}-digit code to ${email}. It is valid for 10 minutes.`
|
||||||
|
: 'Choose a strong new password that meets the security policy.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-xs flex items-start gap-2">
|
<div className="mb-4 p-3 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-xs flex items-start gap-2">
|
||||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||||
@ -173,33 +175,35 @@ export function ForgotPasswordPage() {
|
|||||||
<CheckCircle2 size={36} className="text-emerald-600" />
|
<CheckCircle2 size={36} className="text-emerald-600" />
|
||||||
<span className="font-semibold">{message}</span>
|
<span className="font-semibold">{message}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" full onClick={goToLogin} className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20">
|
<Button type="button" full onClick={goToLogin} className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||||
Go to Login
|
Go to Login
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : step === 'email' ? (
|
) : step === 'email' ? (
|
||||||
/* Step 1: Email Form */
|
/* Step 1: Email Form */
|
||||||
<form onSubmit={handleEmailSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleEmailSubmit} className="flex flex-col gap-4">
|
||||||
<div className="relative flex items-center">
|
<label className="flex flex-col gap-1.5 font-sans">
|
||||||
<Mail size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
<span className="text-sm font-semibold text-emerald-800 flex items-center gap-1.5">
|
||||||
|
<Mail size={15} />
|
||||||
|
Email address <span className="text-emerald-500">*</span>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
aria-label="Email address"
|
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="h-12 w-full pl-11 pr-4 rounded-full border border-slate-200 bg-slate-100/80 outline-none focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400 transition-all text-slate-900 placeholder:text-slate-500 text-sm"
|
className="h-[42px] px-3 border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400 text-sm"
|
||||||
placeholder="Email"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
full
|
full
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||||
>
|
>
|
||||||
{submitting ? 'Sending code…' : 'Send Reset Code'}
|
{submitting ? 'Sending code…' : 'Send Reset Code'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -215,11 +219,12 @@ export function ForgotPasswordPage() {
|
|||||||
) : step === 'code' ? (
|
) : step === 'code' ? (
|
||||||
/* Step 2: OTP Verification Form */
|
/* Step 2: OTP Verification Form */
|
||||||
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-4">
|
||||||
<div className="relative flex items-center">
|
<label className="flex flex-col gap-1.5 font-sans">
|
||||||
<KeyRound size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
<span className="text-sm font-semibold text-emerald-800 flex items-center gap-1.5">
|
||||||
|
<KeyRound size={15} />
|
||||||
|
Reset Code <span className="text-emerald-500">*</span>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={codeInputRef}
|
|
||||||
aria-label="Reset code"
|
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
@ -229,16 +234,16 @@ export function ForgotPasswordPage() {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="h-12 w-full px-11 text-center text-xl font-bold tracking-[0.3em] rounded-full border border-slate-200 bg-slate-100/80 outline-none focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400 transition-all text-slate-900 placeholder:text-slate-400"
|
className="h-[46px] px-3 text-center text-xl font-bold tracking-[0.3em] border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400"
|
||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
full
|
full
|
||||||
disabled={submitting || code.length !== CODE_LENGTH}
|
disabled={submitting || code.length !== CODE_LENGTH}
|
||||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||||
>
|
>
|
||||||
{submitting ? 'Verifying…' : 'Verify Code'}
|
{submitting ? 'Verifying…' : 'Verify Code'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -269,10 +274,11 @@ export function ForgotPasswordPage() {
|
|||||||
/* Step 3: New Password Form */
|
/* Step 3: New Password Form */
|
||||||
<form onSubmit={handlePasswordSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handlePasswordSubmit} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5 font-sans">
|
<div className="flex flex-col gap-1.5 font-sans">
|
||||||
|
<span className="text-sm font-semibold text-emerald-800">
|
||||||
|
New password <span className="text-emerald-500">*</span>
|
||||||
|
</span>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
|
||||||
<input
|
<input
|
||||||
aria-label="New password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={password}
|
value={password}
|
||||||
@ -280,44 +286,45 @@ export function ForgotPasswordPage() {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="h-12 w-full pl-11 pr-11 rounded-full border border-slate-200 bg-slate-100/80 outline-none focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400 transition-all text-slate-900 placeholder:text-slate-500 text-sm"
|
className="h-[42px] w-full px-3 pr-10 border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400 text-sm"
|
||||||
placeholder="New password"
|
placeholder="Enter new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((s) => !s)}
|
onClick={() => setShowPassword((s) => !s)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<PasswordRulesChecklist policy={policy} password={password} email={email} className="mt-1" />
|
<PasswordRulesChecklist policy={policy} password={password} email={email} className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 font-sans">
|
<div className="flex flex-col gap-1.5 font-sans">
|
||||||
|
<span className="text-sm font-semibold text-emerald-800">
|
||||||
|
Confirm new password <span className="text-emerald-500">*</span>
|
||||||
|
</span>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
|
||||||
<input
|
<input
|
||||||
aria-label="Confirm new password"
|
|
||||||
type={showConfirm ? 'text' : 'password'}
|
type={showConfirm ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="h-12 w-full pl-11 pr-11 rounded-full border border-slate-200 bg-slate-100/80 outline-none focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400 transition-all text-slate-900 placeholder:text-slate-500 text-sm"
|
className="h-[42px] w-full px-3 pr-10 border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400 text-sm"
|
||||||
placeholder="Confirm new password"
|
placeholder="Re-enter new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowConfirm((s) => !s)}
|
onClick={() => setShowConfirm((s) => !s)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{showConfirm ? <Eye size={18} /> : <EyeOff size={18} />}
|
{showConfirm ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -326,7 +333,7 @@ export function ForgotPasswordPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
full
|
full
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||||
>
|
>
|
||||||
{submitting ? 'Updating password…' : 'Set New Password'}
|
{submitting ? 'Updating password…' : 'Set New Password'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -344,6 +351,7 @@ export function ForgotPasswordPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</AuthShell>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { Navigate, useNavigate, Link } from 'react-router-dom';
|
import { Navigate, useNavigate, Link } from 'react-router-dom';
|
||||||
import { Eye, EyeOff, Lock, Mail, AlertCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
import { Eye, EyeOff, Lock, AlertCircle, ArrowLeft } from 'lucide-react';
|
||||||
import { useAuth, type PasswordChangeRequiredInfo } from '../auth/context';
|
import { useAuth, type PasswordChangeRequiredInfo } from '../auth/context';
|
||||||
import { Button } from '../components/buttons';
|
import { Button } from '../components/buttons';
|
||||||
import { AuthShell } from '../components/auth/AuthShell';
|
import { Card } from '../components/reusable';
|
||||||
import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist';
|
import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist';
|
||||||
import { usePasswordPolicy } from '../hooks/usePasswordPolicy';
|
import { usePasswordPolicy } from '../hooks/usePasswordPolicy';
|
||||||
import { cn } from '../lib/cn';
|
|
||||||
import {
|
import {
|
||||||
passwordMeetsPolicy,
|
passwordMeetsPolicy,
|
||||||
passwordTooLong,
|
passwordTooLong,
|
||||||
PASSWORD_MAX_CHARS,
|
PASSWORD_MAX_CHARS,
|
||||||
} from '../utils/passwordPolicy';
|
} from '../utils/passwordPolicy';
|
||||||
|
import logo from '../assets/logo.png';
|
||||||
|
|
||||||
/** Login gate. Redirects to /daily once authenticated. */
|
/** Login gate. Redirects to /daily once authenticated. */
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
@ -30,45 +30,20 @@ export function LoginPage() {
|
|||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<{
|
|
||||||
email?: boolean;
|
|
||||||
password?: boolean;
|
|
||||||
newPassword?: boolean;
|
|
||||||
confirmPassword?: boolean;
|
|
||||||
}>({});
|
|
||||||
|
|
||||||
const policy = usePasswordPolicy(!!changeRequired);
|
const policy = usePasswordPolicy(!!changeRequired);
|
||||||
|
|
||||||
if (authed) return <Navigate to="/daily" replace />;
|
if (authed) return <Navigate to="/daily" replace />;
|
||||||
|
|
||||||
async function submit(e: FormEvent) {
|
async function submit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
const errs: typeof fieldErrors = {};
|
|
||||||
if (!email) errs.email = true;
|
|
||||||
if (!password) errs.password = true;
|
|
||||||
|
|
||||||
if (Object.keys(errs).length > 0) {
|
|
||||||
setFieldErrors(errs);
|
|
||||||
setError(
|
|
||||||
errs.email && errs.password
|
|
||||||
? 'Please enter both your email and password.'
|
|
||||||
: errs.email
|
|
||||||
? 'Email address is required.'
|
|
||||||
: 'Password is required.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate('/daily', { replace: true });
|
navigate('/daily', { replace: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Backend signals that password policy or expiry requires a new password first
|
// Backend signals that password policy or expiry requires a new password first
|
||||||
if (err?.code === 'password_change_required' || err?.data?.code === 'password_change_required' || (err?.status === 403 && err?.reason === 'policy')) {
|
if (err?.code === 'password_change_required' || err?.data?.code === 'password_change_required' || err?.status === 403 && err?.reason === 'policy') {
|
||||||
setChangeRequired({
|
setChangeRequired({
|
||||||
message: err?.message || err?.data?.error || 'A new password policy is in effect. Please choose a new password to continue.',
|
message: err?.message || err?.data?.error || 'A new password policy is in effect. Please choose a new password to continue.',
|
||||||
reason: err?.reason || err?.data?.reason,
|
reason: err?.reason || err?.data?.reason,
|
||||||
@ -86,44 +61,28 @@ export function LoginPage() {
|
|||||||
async function handleChangePasswordSubmit(e: FormEvent) {
|
async function handleChangePasswordSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
const errs: typeof fieldErrors = {};
|
if (!newPassword || !confirmPassword) {
|
||||||
if (!newPassword) errs.newPassword = true;
|
setError('Please fill in both password fields.');
|
||||||
if (!confirmPassword) errs.confirmPassword = true;
|
|
||||||
|
|
||||||
if (Object.keys(errs).length > 0) {
|
|
||||||
setFieldErrors(errs);
|
|
||||||
setError(
|
|
||||||
errs.newPassword && errs.confirmPassword
|
|
||||||
? 'Please enter and confirm your new password.'
|
|
||||||
: errs.newPassword
|
|
||||||
? 'New password is required.'
|
|
||||||
: 'Please confirm your new password.'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwordMeetsPolicy(policy, newPassword, { email })) {
|
if (!passwordMeetsPolicy(policy, newPassword, { email })) {
|
||||||
setFieldErrors({ newPassword: true });
|
|
||||||
setError('New password does not meet all of the password requirements.');
|
setError('New password does not meet all of the password requirements.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordTooLong(newPassword)) {
|
if (passwordTooLong(newPassword)) {
|
||||||
setFieldErrors({ newPassword: true });
|
|
||||||
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
|
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setFieldErrors({ confirmPassword: true });
|
|
||||||
setError('New passwords do not match.');
|
setError('New passwords do not match.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword === password) {
|
if (newPassword === password) {
|
||||||
setFieldErrors({ newPassword: true });
|
|
||||||
setError('New password must be different from your current password.');
|
setError('New password must be different from your current password.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -143,15 +102,24 @@ export function LoginPage() {
|
|||||||
setChangeRequired(null);
|
setChangeRequired(null);
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
setFieldErrors({});
|
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<div className="min-h-screen flex items-center justify-center p-4 bg-emerald-50">
|
||||||
title={changeRequired ? 'Set a New Password' : 'Welcome Back'}
|
<Card className="w-full max-w-[420px] shadow-lg border-emerald-100 bg-white p-6 sm:p-7">
|
||||||
subtitle={changeRequired ? changeRequired.message : 'Sign in to continue to Krishna Sales'}
|
<div className="flex flex-col gap-2 items-center mb-6 text-center">
|
||||||
>
|
<img src={logo} alt="Krishna Sales Logo" className="h-16 w-auto object-contain" />
|
||||||
|
<h1 className="m-0 text-2xl font-extrabold text-emerald-900 tracking-[-0.02em]">
|
||||||
|
{changeRequired ? 'Set a New Password' : 'Krishna Sales'}
|
||||||
|
</h1>
|
||||||
|
{changeRequired && (
|
||||||
|
<p className="text-xs text-slate-600 mt-1 max-w-[340px] leading-relaxed">
|
||||||
|
{changeRequired.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 text-xs text-rose-700 bg-rose-50 border border-rose-200 p-3 rounded-lg flex items-start gap-2">
|
<div className="mb-4 text-xs text-rose-700 bg-rose-50 border border-rose-200 p-3 rounded-lg flex items-start gap-2">
|
||||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||||
@ -161,7 +129,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{changeRequired ? (
|
{changeRequired ? (
|
||||||
/* Forced Password Change Form */
|
/* Forced Password Change Form */
|
||||||
<form noValidate onSubmit={handleChangePasswordSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleChangePasswordSubmit} className="flex flex-col gap-4">
|
||||||
<div className="p-3 bg-amber-50/80 border border-amber-200 rounded-lg flex items-start gap-2.5 text-xs text-amber-900">
|
<div className="p-3 bg-amber-50/80 border border-amber-200 rounded-lg flex items-start gap-2.5 text-xs text-amber-900">
|
||||||
<Lock size={16} className="text-amber-700 shrink-0 mt-0.5" />
|
<Lock size={16} className="text-amber-700 shrink-0 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
@ -171,36 +139,29 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div className="flex flex-col gap-1.5 font-sans">
|
<div className="flex flex-col gap-1.5 font-sans">
|
||||||
|
<label className="text-xs font-semibold text-slate-700">
|
||||||
|
New password <span className="text-emerald-500">*</span>
|
||||||
|
</label>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
|
||||||
<input
|
<input
|
||||||
type={showNewPassword ? 'text' : 'password'}
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => {
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
setNewPassword(e.target.value);
|
required
|
||||||
if (fieldErrors.newPassword) setFieldErrors((prev) => ({ ...prev, newPassword: false }));
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className="h-[42px] w-full px-3 pr-10 border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400 text-sm"
|
||||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
placeholder="Enter new password"
|
||||||
fieldErrors.newPassword
|
|
||||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
|
||||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
|
||||||
)}
|
|
||||||
aria-label="New password"
|
|
||||||
placeholder="New password"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewPassword((s) => !s)}
|
onClick={() => setShowNewPassword((s) => !s)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{showNewPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
{showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<PasswordRulesChecklist
|
<PasswordRulesChecklist
|
||||||
@ -213,25 +174,18 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{/* Confirm New Password */}
|
{/* Confirm New Password */}
|
||||||
<div className="flex flex-col gap-1.5 font-sans">
|
<div className="flex flex-col gap-1.5 font-sans">
|
||||||
|
<label className="text-xs font-semibold text-slate-700">
|
||||||
|
Confirm new password <span className="text-emerald-500">*</span>
|
||||||
|
</label>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
|
||||||
<input
|
<input
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => {
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
setConfirmPassword(e.target.value);
|
required
|
||||||
if (fieldErrors.confirmPassword) setFieldErrors((prev) => ({ ...prev, confirmPassword: false }));
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className="h-[42px] w-full px-3 pr-10 border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400 text-sm"
|
||||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
|
||||||
fieldErrors.confirmPassword
|
|
||||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
|
||||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
|
||||||
)}
|
|
||||||
aria-label="Confirm new password"
|
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -239,9 +193,9 @@ export function LoginPage() {
|
|||||||
onClick={() => setShowConfirmPassword((s) => !s)}
|
onClick={() => setShowConfirmPassword((s) => !s)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{showConfirmPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -250,9 +204,9 @@ export function LoginPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
full
|
full
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||||
>
|
>
|
||||||
{busy ? 'Updating & Signing in…' : (<>Update Password & Sign In <ArrowRight size={18} className="ml-2" /></>)}
|
{busy ? 'Updating & Signing in…' : 'Update Password & Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -267,80 +221,68 @@ export function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
/* Standard Login Form */
|
/* Standard Login Form */
|
||||||
<form noValidate onSubmit={submit} className="flex flex-col gap-4">
|
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||||
<div className="relative flex items-center">
|
<label className="flex flex-col gap-1.5 font-sans">
|
||||||
<Mail size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
<span className="text-sm font-semibold text-emerald-800">
|
||||||
|
Email <span className="text-emerald-500">*</span>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
aria-label="Email"
|
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => {
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
setEmail(e.target.value);
|
required
|
||||||
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: false }));
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className="h-[42px] px-3 border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400 text-sm"
|
||||||
'h-12 w-full pl-11 pr-4 rounded-full outline-none transition-all text-sm',
|
placeholder="Enter your email"
|
||||||
fieldErrors.email
|
|
||||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
|
||||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
|
||||||
)}
|
|
||||||
placeholder="Email"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-1.5 font-sans">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-emerald-800">
|
||||||
|
Password <span className="text-emerald-500">*</span>
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="text-xs font-semibold text-emerald-700 hover:text-emerald-900 transition-colors"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
aria-label="Password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => {
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
setPassword(e.target.value);
|
required
|
||||||
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: false }));
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className="h-[42px] w-full px-3 pr-10 border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400 text-sm"
|
||||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
placeholder="Enter your password"
|
||||||
fieldErrors.password
|
|
||||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
|
||||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
|
||||||
)}
|
|
||||||
placeholder="Password"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((s) => !s)}
|
onClick={() => setShowPassword((s) => !s)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
|
||||||
>
|
>
|
||||||
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
to="/forgot-password"
|
|
||||||
className="self-end text-sm font-medium text-emerald-900 hover:text-emerald-700 transition-colors"
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
full
|
full
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||||
>
|
>
|
||||||
{busy ? 'Signing in…' : (<>Sign In <ArrowRight size={18} className="ml-2" /></>)}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</AuthShell>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user