Adding license generation functionality

This commit is contained in:
Muluhabt
2026-07-29 16:53:50 +03:00
parent 26cc1f4fd2
commit 5161e278ce
102 changed files with 5995 additions and 21326 deletions

View File

@@ -2,6 +2,7 @@ export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
export type { AuthConfigValue } from './lib/AuthConfig';
export { AuthShell, BrandMark } from './lib/components/AuthShell';
export { ProtectedRoute } from './lib/components/ProtectedRoute';
export { AuthBootstrap } from './lib/components/AuthBootstrap';
export { LoginPage } from './lib/pages/LoginPage';
export { SignupPage } from './lib/pages/SignupPage';
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';

View File

@@ -0,0 +1,70 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useDispatch } from 'react-redux';
import { authStorage } from '../utils/auth-storage';
import { hydrateAuth, logout, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
/**
* Restores the signed-in session before the router renders.
*
* Redux starts empty on every page load while the token lives in
* localStorage, so without this a refresh leaves the app halfsigned-in: the
* route guards see a token and let you through, but pages that read
* `auth.user` think you are a stranger.
*
* A token the server no longer accepts is cleared here rather than left to
* strand the user on a page they cannot act on or sign out of.
*/
export function AuthBootstrap({ children }: { children: ReactNode }) {
const dispatch = useDispatch();
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
async function restore() {
dispatch(hydrateAuth());
const token = authStorage.getToken();
if (!token) {
if (!cancelled) setReady(true);
return;
}
try {
const response = await fetch(`${BASE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const user = (await response.json()) as AuthUser;
if (!cancelled) dispatch(setUser(user));
} else if (response.status === 401 || response.status === 403) {
// Expired or revoked — drop it so the user gets a login screen
// instead of a dead end.
if (!cancelled) dispatch(logout());
}
} catch {
// Offline or the API is down: keep the stored session and let the
// individual screens surface their own errors.
} finally {
if (!cancelled) setReady(true);
}
}
restore();
return () => {
cancelled = true;
};
}, [dispatch]);
// Rendering the router before the session resolves would let the guards
// redirect based on a state that is about to change.
if (!ready) return null;
return <>{children}</>;
}

View File

@@ -74,7 +74,8 @@ export function LoginPage() {
}).unwrap();
dispatch(setUser(me));
let hasProfile = false;
// Load the seafarer profile if one exists, so those screens have it —
// but never gate sign-in on it.
try {
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const result = await profileCheckTrigger({
@@ -85,10 +86,10 @@ export function LoginPage() {
const profile = result.items[0];
authStorage.setProfileId(profile.id);
dispatch(setCurrentProfile(profile));
hasProfile = true;
}
} catch {
// profile not found — redirect to setup
// No profile yet. That is fine — a profile is only needed by the
// seafarer features, not to apply for a licence.
}
if (!me.isPhoneNumberVerified) {
@@ -96,17 +97,11 @@ export function LoginPage() {
state: {
email: me.email,
phoneNumber: me.phoneNumber,
needsProfile: !hasProfile,
},
});
return;
}
if (!hasProfile) {
navigate('/profile-setup');
return;
}
navigate(loginRedirectPath);
} catch (err: unknown) {
const msg =

View File

@@ -38,11 +38,10 @@ export function OTPVerificationPage() {
const location = useLocation();
const { loginRedirectPath } = useAuthConfig();
const state = location.state as
| { email?: string; phoneNumber?: string; needsProfile?: boolean }
| { email?: string; phoneNumber?: string }
| null;
const email = state?.email ?? '';
const phoneNumber = state?.phoneNumber ?? '';
const needsProfile = state?.needsProfile ?? false;
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
const [resendTrigger, { isLoading: resending }] = useApiMutation();
@@ -73,7 +72,7 @@ export function OTPVerificationPage() {
}).unwrap();
notify.success('Phone number verified successfully');
navigate(needsProfile ? '/profile-setup' : loginRedirectPath);
navigate(loginRedirectPath);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
@@ -142,7 +141,7 @@ export function OTPVerificationPage() {
<Stack gap={6} align="center">
<PinInput
length={CODE_LENGTH}
type="text"
type="alphanumeric"
inputMode="text"
oneTimeCode
size="md"

View File

@@ -115,13 +115,12 @@ export function SignupPage() {
dispatch(setUser(me));
if (data.isPhoneNumberVerified) {
navigate('/profile-setup');
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: {
email: values.email,
phoneNumber: values.phoneNumber,
needsProfile: true,
},
});
}

View File

@@ -47,7 +47,7 @@ export interface CurrentProfileAddress {
postalAddress: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
emergencycontactRelation: string | null;
emergencyContactRelation: string | null;
isActive: boolean;
}

View File

@@ -13,7 +13,9 @@ export async function refreshAccessToken(): Promise<string> {
const refreshToken = authStorage.getRefreshToken();
if (!refreshToken) throw new Error('No refresh token available');
const response = await fetch(`${BASE_API_URL}/auth/refresh`, {
// The IAM service exposes this as `refresh-token`; posting to `/auth/refresh`
// 404s, which the catch below turns into a silent logout.
const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),