Compare commits
No commits in common. "6b9ad95811c0779dd3f37c60fd04b4fa093f322e" and "9e8f157d7079d9b992a585d807d24e1a13fbbb7c" have entirely different histories.
6b9ad95811
...
9e8f157d70
@ -22,7 +22,6 @@ import { InvitationService } from '../services/invitation.service';
|
|||||||
import { InvitationsController } from '../controllers/invitations.controller';
|
import { InvitationsController } from '../controllers/invitations.controller';
|
||||||
import { EmailModule } from '../../infrastructure/email/email.module';
|
import { EmailModule } from '../../infrastructure/email/email.module';
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -54,9 +53,6 @@ import { AuditModule } from '../audit/audit.module';
|
|||||||
|
|
||||||
// Subscriptions module for license checks
|
// Subscriptions module for license checks
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
|
|
||||||
// Audit module for login/logout tracking
|
|
||||||
AuditModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AuthController, InvitationsController],
|
controllers: [AuthController, InvitationsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -235,11 +235,9 @@ export class AuthService {
|
|||||||
* The revocation list lives in Redis with a TTL matching the token's
|
* The revocation list lives in Redis with a TTL matching the token's
|
||||||
* remaining lifetime, so entries clean themselves up.
|
* remaining lifetime, so entries clean themselves up.
|
||||||
*/
|
*/
|
||||||
async logout(
|
async logout(refreshToken?: string): Promise<void> {
|
||||||
refreshToken?: string
|
|
||||||
): Promise<{ userId: string; email: string; organizationId: string } | null> {
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -250,19 +248,9 @@ export class AuthService {
|
|||||||
|
|
||||||
await this.cache.set(this.revokedTokenKey(refreshToken), true, remainingSeconds);
|
await this.cache.set(this.revokedTokenKey(refreshToken), true, remainingSeconds);
|
||||||
this.logger.log(`Refresh token revoked for user: ${payload?.email ?? 'unknown'}`);
|
this.logger.log(`Refresh token revoked for user: ${payload?.email ?? 'unknown'}`);
|
||||||
|
|
||||||
if (payload?.sub) {
|
|
||||||
return {
|
|
||||||
userId: payload.sub,
|
|
||||||
email: payload.email,
|
|
||||||
organizationId: payload.organizationId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Never block logout on revocation failures — log and continue
|
// Never block logout on revocation failures — log and continue
|
||||||
this.logger.error(`Failed to revoke refresh token: ${error}`);
|
this.logger.error(`Failed to revoke refresh token: ${error}`);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,8 +34,6 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { UserMapper } from '../mappers/user.mapper';
|
import { UserMapper } from '../mappers/user.mapper';
|
||||||
import { InvitationService } from '../services/invitation.service';
|
import { InvitationService } from '../services/invitation.service';
|
||||||
import { AuditService } from '../services/audit.service';
|
|
||||||
import { AuditAction } from '@domain/entities/audit-log.entity';
|
|
||||||
import {
|
import {
|
||||||
AUTH_COOKIE_NAMES,
|
AUTH_COOKIE_NAMES,
|
||||||
authCookieOptions,
|
authCookieOptions,
|
||||||
@ -74,26 +72,9 @@ export class AuthController {
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
private readonly invitationService: InvitationService,
|
private readonly invitationService: InvitationService,
|
||||||
private readonly auditService: AuditService,
|
|
||||||
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
|
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the client IP and user agent from the request so we can record
|
|
||||||
* *who* connected and *from where* in the audit trail.
|
|
||||||
*/
|
|
||||||
private getClientInfo(req: Request): { ipAddress?: string; userAgent?: string } {
|
|
||||||
const forwardedFor = req.headers['x-forwarded-for'];
|
|
||||||
const ipAddress =
|
|
||||||
(Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(',')[0]?.trim()) ||
|
|
||||||
req.ip ||
|
|
||||||
req.socket?.remoteAddress;
|
|
||||||
return {
|
|
||||||
ipAddress,
|
|
||||||
userAgent: req.headers['user-agent'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deliver tokens as httpOnly cookies so they are out of reach of XSS.
|
* Deliver tokens as httpOnly cookies so they are out of reach of XSS.
|
||||||
* When rememberMe is false the cookies are session-scoped (cleared when
|
* When rememberMe is false the cookies are session-scoped (cleared when
|
||||||
@ -227,59 +208,18 @@ export class AuthController {
|
|||||||
})
|
})
|
||||||
async login(
|
async login(
|
||||||
@Body() dto: LoginDto,
|
@Body() dto: LoginDto,
|
||||||
@Req() req: Request,
|
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
): Promise<AuthResponseDto> {
|
): Promise<AuthResponseDto> {
|
||||||
const rememberMe = dto.rememberMe === true;
|
const rememberMe = dto.rememberMe === true;
|
||||||
const { ipAddress, userAgent } = this.getClientInfo(req);
|
const result = await this.authService.login(dto.email, dto.password, rememberMe);
|
||||||
|
|
||||||
try {
|
this.setAuthCookies(res, result, rememberMe);
|
||||||
const result = await this.authService.login(dto.email, dto.password, rememberMe);
|
|
||||||
|
|
||||||
this.setAuthCookies(res, result, rememberMe);
|
return {
|
||||||
|
accessToken: result.accessToken,
|
||||||
// Audit log: record who logged in, when and from where
|
refreshToken: result.refreshToken,
|
||||||
await this.auditService.logSuccess(
|
user: result.user,
|
||||||
AuditAction.USER_LOGIN,
|
};
|
||||||
result.user.id,
|
|
||||||
result.user.email,
|
|
||||||
result.user.organizationId,
|
|
||||||
{
|
|
||||||
resourceType: 'user',
|
|
||||||
resourceId: result.user.id,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
metadata: { rememberMe },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Login success: ${result.user.email} from ${ipAddress ?? 'unknown IP'}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken: result.accessToken,
|
|
||||||
refreshToken: result.refreshToken,
|
|
||||||
user: result.user,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
// Audit log: record failed login attempts (the attempted email is the
|
|
||||||
// only identity we have — the credentials did not match a valid user)
|
|
||||||
await this.auditService.logFailure(
|
|
||||||
AuditAction.USER_LOGIN,
|
|
||||||
'unknown',
|
|
||||||
dto.email,
|
|
||||||
'unknown',
|
|
||||||
error?.message || 'Invalid credentials',
|
|
||||||
{
|
|
||||||
resourceType: 'user',
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.warn(`Login failed for ${dto.email} from ${ipAddress ?? 'unknown IP'}`);
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -362,22 +302,9 @@ export class AuthController {
|
|||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken];
|
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken];
|
||||||
|
|
||||||
const loggedOutUser = await this.authService.logout(refreshToken);
|
await this.authService.logout(refreshToken);
|
||||||
this.clearAuthCookies(res);
|
this.clearAuthCookies(res);
|
||||||
|
|
||||||
// Audit log: record who logged out and when
|
|
||||||
if (loggedOutUser) {
|
|
||||||
const { ipAddress, userAgent } = this.getClientInfo(req);
|
|
||||||
await this.auditService.logSuccess(
|
|
||||||
AuditAction.USER_LOGOUT,
|
|
||||||
loggedOutUser.userId,
|
|
||||||
loggedOutUser.email,
|
|
||||||
loggedOutUser.organizationId,
|
|
||||||
{ resourceType: 'user', resourceId: loggedOutUser.userId, ipAddress, userAgent }
|
|
||||||
);
|
|
||||||
this.logger.log(`Logout: ${loggedOutUser.email} from ${ipAddress ?? 'unknown IP'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: 'Logout successful' };
|
return { message: 'Logout successful' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,211 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
|
||||||
import { Link, usePathname, useRouter } from '@/i18n/navigation';
|
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
|
||||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import {
|
|
||||||
Users,
|
|
||||||
Building2,
|
|
||||||
Package,
|
|
||||||
FileText,
|
|
||||||
BarChart3,
|
|
||||||
Newspaper,
|
|
||||||
ScrollText,
|
|
||||||
ArrowLeft,
|
|
||||||
LogOut,
|
|
||||||
ShieldCheck,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface AdminNavItem {
|
|
||||||
key: string;
|
|
||||||
href: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminNavItems: AdminNavItem[] = [
|
|
||||||
{ key: 'users', href: '/admin/users', icon: Users },
|
|
||||||
{ key: 'organizations', href: '/admin/organizations', icon: Building2 },
|
|
||||||
{ key: 'bookings', href: '/admin/bookings', icon: Package },
|
|
||||||
{ key: 'documents', href: '/admin/documents', icon: FileText },
|
|
||||||
{ key: 'csvRates', href: '/admin/csv-rates', icon: BarChart3 },
|
|
||||||
{ key: 'blog', href: '/admin/blog', icon: Newspaper },
|
|
||||||
{ key: 'logs', href: '/admin/logs', icon: ScrollText },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
const { user, logout, loading, isAuthenticated } = useAuth();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
const tItems = useTranslations('components.adminPanelDropdown');
|
|
||||||
const tAdmin = useTranslations('admin');
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
// The /admin/login page lives under this segment but must render without the
|
|
||||||
// admin chrome and without triggering the role guard.
|
|
||||||
const isLoginRoute = pathname === '/admin/login';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoginRoute || loading) return;
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
router.replace('/admin/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (user && user.role !== 'ADMIN') {
|
|
||||||
router.replace('/dashboard');
|
|
||||||
}
|
|
||||||
}, [isLoginRoute, loading, isAuthenticated, user, router]);
|
|
||||||
|
|
||||||
if (isLoginRoute) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorized = isAuthenticated && user?.role === 'ADMIN';
|
|
||||||
|
|
||||||
if (loading || !authorized) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
||||||
<div className="w-8 h-8 border-4 border-brand-turquoise border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
{sidebarOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-brand-navy text-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
|
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex items-center justify-between h-16 px-6 border-b border-white/10">
|
|
||||||
<Link href="/admin" className="flex items-center gap-2">
|
|
||||||
<Image
|
|
||||||
src="/assets/logos/logo-white.svg"
|
|
||||||
alt="Xpeditis"
|
|
||||||
width={44}
|
|
||||||
height={52}
|
|
||||||
priority
|
|
||||||
className="h-auto"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
className="lg:hidden text-white/70 hover:text-white"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
aria-label="Close menu"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 px-6 py-4 text-xs font-semibold uppercase tracking-wider text-brand-turquoise">
|
|
||||||
<ShieldCheck className="w-4 h-4" />
|
|
||||||
{tAdmin('panelTitle')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 px-4 pb-6 space-y-1 overflow-y-auto">
|
|
||||||
{adminNavItems.map(item => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.key}
|
|
||||||
href={item.href}
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
|
|
||||||
isActive(item.href)
|
|
||||||
? 'bg-brand-turquoise text-brand-navy'
|
|
||||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="mr-3 h-5 w-5" />
|
|
||||||
<span className="flex-1">{tItems(`items.${item.key}` as any)}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="border-t border-white/10 p-4 space-y-3">
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="flex items-center px-4 py-2.5 text-sm font-medium text-white/80 rounded-lg hover:bg-white/10 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
{tAdmin('backToApp')}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 px-2">
|
|
||||||
<div className="w-10 h-10 bg-brand-turquoise rounded-full flex items-center justify-center text-brand-navy font-semibold">
|
|
||||||
{user?.firstName?.[0]}
|
|
||||||
{user?.lastName?.[0]}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-white truncate">
|
|
||||||
{user?.firstName} {user?.lastName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-white/60 truncate">{user?.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-200 bg-red-500/20 rounded-lg hover:bg-red-500/30 transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
|
||||||
{tAdmin('logout')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:pl-64">
|
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-white border-b">
|
|
||||||
<button
|
|
||||||
className="lg:hidden text-gray-500 hover:text-gray-700 p-1"
|
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
aria-label="Open menu"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<h1 className="text-base lg:text-xl font-semibold text-gray-900 ml-3 lg:ml-0">
|
|
||||||
{adminNavItems.find(item => isActive(item.href))
|
|
||||||
? tItems(`items.${adminNavItems.find(item => isActive(item.href))!.key}` as any)
|
|
||||||
: tAdmin('title')}
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center space-x-3 lg:space-x-4">
|
|
||||||
<LanguageSwitcher variant="light" />
|
|
||||||
<NotificationDropdown />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="p-4 lg:p-6">{children}</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { Link, useRouter } from '@/i18n/navigation';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { ShieldCheck, Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { login as apiLogin, getCurrentUser, logout as apiLogout } from '@/lib/api/auth';
|
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
|
||||||
const t = useTranslations('admin.login');
|
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
const router = useRouter();
|
|
||||||
const { refreshUser } = useAuth();
|
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiLogin({ email, password, rememberMe: false });
|
|
||||||
const me = await getCurrentUser();
|
|
||||||
|
|
||||||
if (me.role !== 'ADMIN') {
|
|
||||||
// Valid credentials but not an administrator — drop the session.
|
|
||||||
await apiLogout();
|
|
||||||
setError(t('notAdmin'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshUser();
|
|
||||||
router.replace('/admin');
|
|
||||||
} catch (err) {
|
|
||||||
setError(t('error'));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-brand-navy px-4">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy to-neutral-900 opacity-95" />
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-md">
|
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8 sm:p-10">
|
|
||||||
<div className="flex flex-col items-center mb-8">
|
|
||||||
<Image
|
|
||||||
src="/assets/logos/logo-black.svg"
|
|
||||||
alt="Xpeditis"
|
|
||||||
width={56}
|
|
||||||
height={64}
|
|
||||||
priority
|
|
||||||
className="h-auto mb-4"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2 text-brand-turquoise">
|
|
||||||
<ShieldCheck className="w-5 h-5" />
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider">{t('badge')}</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-h2 text-brand-navy mt-3 text-center">{t('title')}</h1>
|
|
||||||
<p className="text-body-sm text-neutral-600 mt-1 text-center">{t('subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p className="text-body-sm text-red-800">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="label">
|
|
||||||
{t('emailLabel')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={e => setEmail(e.target.value)}
|
|
||||||
className="input w-full"
|
|
||||||
placeholder={t('emailPlaceholder')}
|
|
||||||
autoComplete="email"
|
|
||||||
disabled={isLoading}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="label">
|
|
||||||
{t('passwordLabel')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
className="input w-full pr-12"
|
|
||||||
placeholder={t('passwordPlaceholder')}
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={isLoading}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isLoading ? t('submitting') : t('submit')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<Link href="/login" className="text-body-sm link">
|
|
||||||
{t('backToApp')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
type Params = { locale: string };
|
|
||||||
|
|
||||||
export default async function AdminIndexPage({ params }: { params: Promise<Params> }) {
|
|
||||||
const { locale } = await params;
|
|
||||||
redirect(`/${locale}/admin/users`);
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
// Legacy redirect: the admin area moved from /dashboard/admin/* to /admin/*.
|
|
||||||
// Keeps old links and bookmarks working.
|
|
||||||
type Params = { locale: string; rest?: string[] };
|
|
||||||
|
|
||||||
export default async function LegacyAdminRedirect({ params }: { params: Promise<Params> }) {
|
|
||||||
const { locale, rest } = await params;
|
|
||||||
const sub = rest && rest.length ? `/${rest.join('/')}` : '';
|
|
||||||
redirect(`/${locale}/admin${sub}`);
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
|
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
|
||||||
import { createUser } from '@/lib/api/users';
|
import { createUser } from '@/lib/api/users';
|
||||||
import { getAllOrganizations } from '@/lib/api/admin';
|
import { getAllOrganizations } from '@/lib/api/admin';
|
||||||
@ -28,10 +27,8 @@ interface Organization {
|
|||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const t = useTranslations('dashboard.admin.users');
|
const t = useTranslations('dashboard.admin.users');
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
|
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -352,23 +349,12 @@ export default function AdminUsersPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
{t('modal.password')}
|
{t('modal.password')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative mt-1">
|
<input
|
||||||
<input
|
type="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
value={formData.password}
|
||||||
value={formData.password}
|
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
||||||
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end space-x-2 pt-4">
|
<div className="flex justify-end space-x-2 pt-4">
|
||||||
<button
|
<button
|
||||||
@ -6,6 +6,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
import NotificationDropdown from '@/components/NotificationDropdown';
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
|
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@ -20,7 +21,6 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Home,
|
Home,
|
||||||
User,
|
User,
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
import { useSubscription } from '@/lib/context/subscription-context';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
@ -159,13 +159,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
|
|
||||||
{user?.role === 'ADMIN' && (
|
{user?.role === 'ADMIN' && (
|
||||||
<div className="pt-4 mt-4 border-t">
|
<div className="pt-4 mt-4 border-t">
|
||||||
<Link
|
<AdminPanelDropdown />
|
||||||
href="/admin"
|
|
||||||
className="flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors bg-brand-navy text-white hover:bg-brand-navy/90"
|
|
||||||
>
|
|
||||||
<ShieldCheck className="mr-3 h-5 w-5" />
|
|
||||||
<span className="flex-1">{t('nav.admin')}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -7,20 +7,15 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { updateUser, changePassword } from '@/lib/api';
|
import { updateUser, changePassword } from '@/lib/api';
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const t = useTranslations('dashboard.profile');
|
const t = useTranslations('dashboard.profile');
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
const { user, refreshUser, loading } = useAuth();
|
const { user, refreshUser, loading } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
|
||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
||||||
|
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.object({
|
.object({
|
||||||
@ -331,30 +326,13 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
{t('passwordForm.current')}
|
{t('passwordForm.current')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
{...passwordForm.register('currentPassword')}
|
||||||
{...passwordForm.register('currentPassword')}
|
type="password"
|
||||||
type={showCurrentPassword ? 'text' : 'password'}
|
id="currentPassword"
|
||||||
id="currentPassword"
|
autoComplete="current-password"
|
||||||
autoComplete="current-password"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={
|
|
||||||
showCurrentPassword ? tCommon('hidePassword') : tCommon('showPassword')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showCurrentPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{passwordForm.formState.errors.currentPassword && (
|
{passwordForm.formState.errors.currentPassword && (
|
||||||
<p className="mt-1 text-sm text-red-600">
|
<p className="mt-1 text-sm text-red-600">
|
||||||
{passwordForm.formState.errors.currentPassword.message}
|
{passwordForm.formState.errors.currentPassword.message}
|
||||||
@ -369,24 +347,13 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
{t('passwordForm.new')}
|
{t('passwordForm.new')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
{...passwordForm.register('newPassword')}
|
||||||
{...passwordForm.register('newPassword')}
|
type="password"
|
||||||
type={showNewPassword ? 'text' : 'password'}
|
id="newPassword"
|
||||||
id="newPassword"
|
autoComplete="new-password"
|
||||||
autoComplete="new-password"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={showNewPassword ? tCommon('hidePassword') : tCommon('showPassword')}
|
|
||||||
>
|
|
||||||
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{passwordForm.formState.errors.newPassword && (
|
{passwordForm.formState.errors.newPassword && (
|
||||||
<p className="mt-1 text-sm text-red-600">
|
<p className="mt-1 text-sm text-red-600">
|
||||||
{passwordForm.formState.errors.newPassword.message}
|
{passwordForm.formState.errors.newPassword.message}
|
||||||
@ -402,30 +369,13 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
{t('passwordForm.confirm')}
|
{t('passwordForm.confirm')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
{...passwordForm.register('confirmPassword')}
|
||||||
{...passwordForm.register('confirmPassword')}
|
type="password"
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
id="confirmPassword"
|
||||||
id="confirmPassword"
|
autoComplete="new-password"
|
||||||
autoComplete="new-password"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={
|
|
||||||
showConfirmPassword ? tCommon('hidePassword') : tCommon('showPassword')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showConfirmPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{passwordForm.formState.errors.confirmPassword && (
|
{passwordForm.formState.errors.confirmPassword && (
|
||||||
<p className="mt-1 text-sm text-red-600">
|
<p className="mt-1 text-sm text-red-600">
|
||||||
{passwordForm.formState.errors.confirmPassword.message}
|
{passwordForm.formState.errors.confirmPassword.message}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { Link } from '@/i18n/navigation';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
|
|
||||||
function AnimatedCounter({
|
function AnimatedCounter({
|
||||||
@ -98,11 +97,9 @@ function LoginPageContent() {
|
|||||||
const tLogin = useTranslations('auth.login');
|
const tLogin = useTranslations('auth.login');
|
||||||
const tPanel = useTranslations('auth.sidePanel');
|
const tPanel = useTranslations('auth.sidePanel');
|
||||||
const tFooter = useTranslations('auth.footerLinks');
|
const tFooter = useTranslations('auth.footerLinks');
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
@ -265,33 +262,22 @@ function LoginPageContent() {
|
|||||||
>
|
>
|
||||||
{tLogin('passwordLabel')}
|
{tLogin('passwordLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
id="password"
|
||||||
id="password"
|
type="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
value={password}
|
||||||
value={password}
|
onChange={handlePasswordChange}
|
||||||
onChange={handlePasswordChange}
|
className={`input w-full ${
|
||||||
className={`input w-full pr-12 ${
|
fieldErrors.password
|
||||||
fieldErrors.password
|
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
|
||||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
placeholder={tLogin('passwordPlaceholder')}
|
||||||
placeholder={tLogin('passwordPlaceholder')}
|
autoComplete="current-password"
|
||||||
autoComplete="current-password"
|
disabled={isLoading}
|
||||||
disabled={isLoading}
|
aria-invalid={!!fieldErrors.password}
|
||||||
aria-invalid={!!fieldErrors.password}
|
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
||||||
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{fieldErrors.password && (
|
{fieldErrors.password && (
|
||||||
<p
|
<p
|
||||||
id="password-error"
|
id="password-error"
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import { Link, useRouter } from '@/i18n/navigation';
|
import { Link, useRouter } from '@/i18n/navigation';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { register } from '@/lib/api';
|
import { register } from '@/lib/api';
|
||||||
import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations';
|
import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations';
|
||||||
import type { OrganizationType } from '@/types/api';
|
import type { OrganizationType } from '@/types/api';
|
||||||
@ -58,7 +57,6 @@ function RegisterPageContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations('auth.register');
|
const t = useTranslations('auth.register');
|
||||||
const tFooter = useTranslations('auth.footerLinks');
|
const tFooter = useTranslations('auth.footerLinks');
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
|
|
||||||
const [step, setStep] = useState<1 | 2>(1);
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
const [statsActive, setStatsActive] = useState(false);
|
const [statsActive, setStatsActive] = useState(false);
|
||||||
@ -83,8 +81,6 @@ function RegisterPageContent() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
||||||
|
|
||||||
const [organizationName, setOrganizationName] = useState('');
|
const [organizationName, setOrganizationName] = useState('');
|
||||||
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
|
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
|
||||||
@ -517,28 +513,17 @@ function RegisterPageContent() {
|
|||||||
<label htmlFor="password" className="label">
|
<label htmlFor="password" className="label">
|
||||||
{t('passwordLabel')}
|
{t('passwordLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
id="password"
|
||||||
id="password"
|
type="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
required
|
||||||
required
|
value={password}
|
||||||
value={password}
|
onChange={e => setPassword(e.target.value)}
|
||||||
onChange={e => setPassword(e.target.value)}
|
className="input w-full"
|
||||||
className="input w-full pr-12"
|
placeholder={t('passwordPlaceholder')}
|
||||||
placeholder={t('passwordPlaceholder')}
|
autoComplete="new-password"
|
||||||
autoComplete="new-password"
|
disabled={isLoading}
|
||||||
disabled={isLoading}
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-body-xs text-neutral-500">{t('passwordHint')}</p>
|
<p className="mt-1.5 text-body-xs text-neutral-500">{t('passwordHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -546,34 +531,17 @@ function RegisterPageContent() {
|
|||||||
<label htmlFor="confirmPassword" className="label">
|
<label htmlFor="confirmPassword" className="label">
|
||||||
{t('confirmPasswordLabel')}
|
{t('confirmPasswordLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
id="confirmPassword"
|
||||||
id="confirmPassword"
|
type="password"
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
required
|
||||||
required
|
value={confirmPassword}
|
||||||
value={confirmPassword}
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
className="input w-full"
|
||||||
className="input w-full pr-12"
|
placeholder={t('passwordPlaceholder')}
|
||||||
placeholder={t('passwordPlaceholder')}
|
autoComplete="new-password"
|
||||||
autoComplete="new-password"
|
disabled={isLoading}
|
||||||
disabled={isLoading}
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={
|
|
||||||
showConfirmPassword ? tCommon('hidePassword') : tCommon('showPassword')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showConfirmPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -5,20 +5,16 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import { Link, useRouter } from '@/i18n/navigation';
|
import { Link, useRouter } from '@/i18n/navigation';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { resetPassword } from '@/lib/api/auth';
|
import { resetPassword } from '@/lib/api/auth';
|
||||||
|
|
||||||
function ResetPasswordContent() {
|
function ResetPasswordContent() {
|
||||||
const t = useTranslations('auth.resetPassword');
|
const t = useTranslations('auth.resetPassword');
|
||||||
const tFooter = useTranslations('auth.footerLinks');
|
const tFooter = useTranslations('auth.footerLinks');
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -163,28 +159,17 @@ function ResetPasswordContent() {
|
|||||||
<label htmlFor="password" className="label">
|
<label htmlFor="password" className="label">
|
||||||
{t('passwordLabel')}
|
{t('passwordLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
id="password"
|
||||||
id="password"
|
type="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
required
|
||||||
required
|
value={password}
|
||||||
value={password}
|
onChange={e => setPassword(e.target.value)}
|
||||||
onChange={e => setPassword(e.target.value)}
|
className="input w-full"
|
||||||
className="input w-full pr-12"
|
placeholder="••••••••••••"
|
||||||
placeholder="••••••••••••"
|
autoComplete="new-password"
|
||||||
autoComplete="new-password"
|
disabled={loading}
|
||||||
disabled={loading}
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-body-xs text-neutral-500">{t('passwordHint')}</p>
|
<p className="mt-1.5 text-body-xs text-neutral-500">{t('passwordHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -192,34 +177,17 @@ function ResetPasswordContent() {
|
|||||||
<label htmlFor="confirmPassword" className="label">
|
<label htmlFor="confirmPassword" className="label">
|
||||||
{t('confirmPasswordLabel')}
|
{t('confirmPasswordLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
id="confirmPassword"
|
||||||
id="confirmPassword"
|
type="password"
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
required
|
||||||
required
|
value={confirmPassword}
|
||||||
value={confirmPassword}
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
className="input w-full"
|
||||||
className="input w-full pr-12"
|
placeholder="••••••••••••"
|
||||||
placeholder="••••••••••••"
|
autoComplete="new-password"
|
||||||
autoComplete="new-password"
|
disabled={loading}
|
||||||
disabled={loading}
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={
|
|
||||||
showConfirmPassword ? tCommon('hidePassword') : tCommon('showPassword')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showConfirmPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -25,29 +25,7 @@
|
|||||||
"discover": "Discover",
|
"discover": "Discover",
|
||||||
"tryNow": "Try now",
|
"tryNow": "Try now",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"submit": "Submit",
|
"submit": "Submit"
|
||||||
"showPassword": "Show password",
|
|
||||||
"hidePassword": "Hide password"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"title": "Administration",
|
|
||||||
"panelTitle": "Administration",
|
|
||||||
"backToApp": "Back to app",
|
|
||||||
"logout": "Log out",
|
|
||||||
"login": {
|
|
||||||
"badge": "Restricted access",
|
|
||||||
"title": "Admin sign in",
|
|
||||||
"subtitle": "Access the Xpeditis administration area",
|
|
||||||
"emailLabel": "Email",
|
|
||||||
"emailPlaceholder": "you@company.com",
|
|
||||||
"passwordLabel": "Password",
|
|
||||||
"passwordPlaceholder": "********",
|
|
||||||
"submit": "Sign in",
|
|
||||||
"submitting": "Signing in...",
|
|
||||||
"notAdmin": "Access denied: this account is not an administrator.",
|
|
||||||
"error": "Sign in failed. Please check your credentials.",
|
|
||||||
"backToApp": "Back to site"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
@ -325,8 +303,7 @@
|
|||||||
"wiki": "Maritime Wiki",
|
"wiki": "Maritime Wiki",
|
||||||
"organization": "Organization",
|
"organization": "Organization",
|
||||||
"apiKeys": "API Keys",
|
"apiKeys": "API Keys",
|
||||||
"users": "Users",
|
"users": "Users"
|
||||||
"admin": "Administration"
|
|
||||||
},
|
},
|
||||||
"topbar": {
|
"topbar": {
|
||||||
"defaultTitle": "Dashboard"
|
"defaultTitle": "Dashboard"
|
||||||
@ -1746,10 +1723,7 @@
|
|||||||
],
|
],
|
||||||
"extensionsTitle": "Coverage Extensions",
|
"extensionsTitle": "Coverage Extensions",
|
||||||
"extensions": [
|
"extensions": [
|
||||||
{
|
{ "name": "War clause", "description": "Covers losses due to war, terrorism, piracy" },
|
||||||
"name": "War clause",
|
|
||||||
"description": "Covers losses due to war, terrorism, piracy"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Strikes clause",
|
"name": "Strikes clause",
|
||||||
"description": "Covers losses due to strikes, riots, civil commotion"
|
"description": "Covers losses due to strikes, riots, civil commotion"
|
||||||
@ -1875,42 +1849,15 @@
|
|||||||
"colItem": "Item",
|
"colItem": "Item",
|
||||||
"colAmount": "Amount",
|
"colAmount": "Amount",
|
||||||
"exampleItems": [
|
"exampleItems": [
|
||||||
{
|
{ "item": "Base ocean freight", "amount": "1,200 USD" },
|
||||||
"item": "Base ocean freight",
|
{ "item": "BAF (Bunker)", "amount": "350 USD" },
|
||||||
"amount": "1,200 USD"
|
{ "item": "CAF (Currency)", "amount": "50 USD" },
|
||||||
},
|
{ "item": "THC Origin", "amount": "180 USD" },
|
||||||
{
|
{ "item": "THC Destination", "amount": "220 USD" },
|
||||||
"item": "BAF (Bunker)",
|
{ "item": "B/L Fee", "amount": "55 USD" },
|
||||||
"amount": "350 USD"
|
{ "item": "ISPS", "amount": "30 USD" },
|
||||||
},
|
{ "item": "Pre-carriage", "amount": "250 USD" },
|
||||||
{
|
{ "item": "Total", "amount": "2,335 USD" }
|
||||||
"item": "CAF (Currency)",
|
|
||||||
"amount": "50 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "THC Origin",
|
|
||||||
"amount": "180 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "THC Destination",
|
|
||||||
"amount": "220 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "B/L Fee",
|
|
||||||
"amount": "55 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "ISPS",
|
|
||||||
"amount": "30 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "Pre-carriage",
|
|
||||||
"amount": "250 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "Total",
|
|
||||||
"amount": "2,335 USD"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"conteneurs": {
|
"conteneurs": {
|
||||||
@ -1982,10 +1929,7 @@
|
|||||||
],
|
],
|
||||||
"specialEquipmentTitle": "Special Equipment",
|
"specialEquipmentTitle": "Special Equipment",
|
||||||
"specialEquipment": [
|
"specialEquipment": [
|
||||||
{
|
{ "name": "ISO Tank", "description": "For liquids, chemicals, food products in bulk" },
|
||||||
"name": "ISO Tank",
|
|
||||||
"description": "For liquids, chemicals, food products in bulk"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Bulk Container",
|
"name": "Bulk Container",
|
||||||
"description": "For dry bulk (grains, minerals) — top hatch"
|
"description": "For dry bulk (grains, minerals) — top hatch"
|
||||||
@ -2007,26 +1951,11 @@
|
|||||||
"condition": "Standard general cargo",
|
"condition": "Standard general cargo",
|
||||||
"recommendation": "20' or 40' Dry depending on volume"
|
"recommendation": "20' or 40' Dry depending on volume"
|
||||||
},
|
},
|
||||||
{
|
{ "condition": "Temperature-sensitive goods", "recommendation": "Reefer 20' or 40'" },
|
||||||
"condition": "Temperature-sensitive goods",
|
{ "condition": "Over-height cargo (> 2.2m)", "recommendation": "Open Top or Flat Rack" },
|
||||||
"recommendation": "Reefer 20' or 40'"
|
{ "condition": "Over-length/weight cargo", "recommendation": "Flat Rack or Platform" },
|
||||||
},
|
{ "condition": "Bulk liquids", "recommendation": "ISO Tank" },
|
||||||
{
|
{ "condition": "Volume < 15 m³", "recommendation": "Consider LCL" }
|
||||||
"condition": "Over-height cargo (> 2.2m)",
|
|
||||||
"recommendation": "Open Top or Flat Rack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"condition": "Over-length/weight cargo",
|
|
||||||
"recommendation": "Flat Rack or Platform"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"condition": "Bulk liquids",
|
|
||||||
"recommendation": "ISO Tank"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"condition": "Volume < 15 m³",
|
|
||||||
"recommendation": "Consider LCL"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"documentsTransport": {
|
"documentsTransport": {
|
||||||
@ -2214,10 +2143,7 @@
|
|||||||
"type": "VAT",
|
"type": "VAT",
|
||||||
"description": "Applied on (customs value + import duties + transport). 20% standard rate."
|
"description": "Applied on (customs value + import duties + transport). 20% standard rate."
|
||||||
},
|
},
|
||||||
{
|
{ "type": "Excise Duties", "description": "Specific to alcohol, tobacco, hydrocarbons." }
|
||||||
"type": "Excise Duties",
|
|
||||||
"description": "Specific to alcohol, tobacco, hydrocarbons."
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"imdg": {
|
"imdg": {
|
||||||
@ -2276,16 +2202,8 @@
|
|||||||
"description": "Toxic and infectious substances",
|
"description": "Toxic and infectious substances",
|
||||||
"subdivisions": ["6.1 Toxic substances", "6.2 Infectious substances"]
|
"subdivisions": ["6.1 Toxic substances", "6.2 Infectious substances"]
|
||||||
},
|
},
|
||||||
{
|
{ "class": "Class 7", "name": "Radioactive", "description": "Radioactive materials" },
|
||||||
"class": "Class 7",
|
{ "class": "Class 8", "name": "Corrosive", "description": "Corrosive substances" },
|
||||||
"name": "Radioactive",
|
|
||||||
"description": "Radioactive materials"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"class": "Class 8",
|
|
||||||
"name": "Corrosive",
|
|
||||||
"description": "Corrosive substances"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"class": "Class 9",
|
"class": "Class 9",
|
||||||
"name": "Miscellaneous",
|
"name": "Miscellaneous",
|
||||||
@ -2321,14 +2239,8 @@
|
|||||||
"group": "Group I (X)",
|
"group": "Group I (X)",
|
||||||
"description": "High danger — most stringent packaging requirements"
|
"description": "High danger — most stringent packaging requirements"
|
||||||
},
|
},
|
||||||
{
|
{ "group": "Group II (Y)", "description": "Medium danger — standard packaging" },
|
||||||
"group": "Group II (Y)",
|
{ "group": "Group III (Z)", "description": "Low danger — less stringent requirements" }
|
||||||
"description": "Medium danger — standard packaging"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "Group III (Z)",
|
|
||||||
"description": "Low danger — less stringent requirements"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"labelingTitle": "Labeling and Placarding",
|
"labelingTitle": "Labeling and Placarding",
|
||||||
"labelingContent": "Each package must display: UN number, proper shipping name, hazard labels and class. Containers must display 250mm × 250mm placards matching the IMDG class. Mixed loads require labels for each dangerous good.",
|
"labelingContent": "Each package must display: UN number, proper shipping name, hazard labels and class. Containers must display 250mm × 250mm placards matching the IMDG class. Mixed loads require labels for each dangerous good.",
|
||||||
@ -2352,11 +2264,7 @@
|
|||||||
"lcl": "< 15 m³ or < 10 tonnes",
|
"lcl": "< 15 m³ or < 10 tonnes",
|
||||||
"fcl": "> 15 m³ or full container"
|
"fcl": "> 15 m³ or full container"
|
||||||
},
|
},
|
||||||
{
|
{ "criterion": "Price", "lcl": "Per CBM (m³) or tonne", "fcl": "Fixed per container" },
|
||||||
"criterion": "Price",
|
|
||||||
"lcl": "Per CBM (m³) or tonne",
|
|
||||||
"fcl": "Fixed per container"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"criterion": "Security",
|
"criterion": "Security",
|
||||||
"lcl": "Moderate (shared with others)",
|
"lcl": "Moderate (shared with others)",
|
||||||
@ -2465,10 +2373,7 @@
|
|||||||
"role": "Applicant (Importer)",
|
"role": "Applicant (Importer)",
|
||||||
"description": "The buyer who requests the L/C at their bank"
|
"description": "The buyer who requests the L/C at their bank"
|
||||||
},
|
},
|
||||||
{
|
{ "role": "Issuing Bank", "description": "The importer's bank that issues the L/C" },
|
||||||
"role": "Issuing Bank",
|
|
||||||
"description": "The importer's bank that issues the L/C"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"role": "Beneficiary (Exporter)",
|
"role": "Beneficiary (Exporter)",
|
||||||
"description": "The seller who benefits from the L/C"
|
"description": "The seller who benefits from the L/C"
|
||||||
@ -2492,10 +2397,7 @@
|
|||||||
"name": "Commercial Invoice",
|
"name": "Commercial Invoice",
|
||||||
"description": "In exact conformity with the L/C — amounts, currencies, description"
|
"description": "In exact conformity with the L/C — amounts, currencies, description"
|
||||||
},
|
},
|
||||||
{
|
{ "name": "Packing List", "description": "Consistent with invoice and B/L" },
|
||||||
"name": "Packing List",
|
|
||||||
"description": "Consistent with invoice and B/L"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Insurance Certificate",
|
"name": "Insurance Certificate",
|
||||||
"description": "Required if CIF or CIP — amounts and coverage per L/C"
|
"description": "Required if CIF or CIP — amounts and coverage per L/C"
|
||||||
@ -2535,10 +2437,7 @@
|
|||||||
"label": "Presentation deadline",
|
"label": "Presentation deadline",
|
||||||
"description": "Number of days after shipment to present documents (typically 21 days)"
|
"description": "Number of days after shipment to present documents (typically 21 days)"
|
||||||
},
|
},
|
||||||
{
|
{ "label": "L/C expiry", "description": "Final deadline for all document presentation" }
|
||||||
"label": "L/C expiry",
|
|
||||||
"description": "Final deadline for all document presentation"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"costsTitle": "Costs",
|
"costsTitle": "Costs",
|
||||||
"costsItems": [
|
"costsItems": [
|
||||||
@ -2550,14 +2449,8 @@
|
|||||||
"label": "Confirmation commission",
|
"label": "Confirmation commission",
|
||||||
"description": "0.2–0.5% per quarter (confirming bank)"
|
"description": "0.2–0.5% per quarter (confirming bank)"
|
||||||
},
|
},
|
||||||
{
|
{ "label": "Amendment fee", "description": "Fixed fee per amendment" },
|
||||||
"label": "Amendment fee",
|
{ "label": "Discrepancy fee", "description": "Fixed fee in case of document discrepancy" }
|
||||||
"description": "Fixed fee per amendment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Discrepancy fee",
|
|
||||||
"description": "Fixed fee in case of document discrepancy"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"portsRoutes": {
|
"portsRoutes": {
|
||||||
@ -2636,66 +2529,16 @@
|
|||||||
"colCountry": "Country",
|
"colCountry": "Country",
|
||||||
"colTeu": "TEU / year",
|
"colTeu": "TEU / year",
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{ "rank": 1, "port": "Shanghai", "country": "China", "teu": "47M" },
|
||||||
"rank": 1,
|
{ "rank": 2, "port": "Singapore", "country": "Singapore", "teu": "37M" },
|
||||||
"port": "Shanghai",
|
{ "rank": 3, "port": "Ningbo-Zhoushan", "country": "China", "teu": "33M" },
|
||||||
"country": "China",
|
{ "rank": 4, "port": "Shenzhen", "country": "China", "teu": "29M" },
|
||||||
"teu": "47M"
|
{ "rank": 5, "port": "Guangzhou", "country": "China", "teu": "24M" },
|
||||||
},
|
{ "rank": 6, "port": "Qingdao", "country": "China", "teu": "24M" },
|
||||||
{
|
{ "rank": 7, "port": "Busan", "country": "South Korea", "teu": "22M" },
|
||||||
"rank": 2,
|
{ "rank": 8, "port": "Tianjin", "country": "China", "teu": "21M" },
|
||||||
"port": "Singapore",
|
{ "rank": 9, "port": "Dubai (Jebel Ali)", "country": "UAE", "teu": "15M" },
|
||||||
"country": "Singapore",
|
{ "rank": 10, "port": "Rotterdam", "country": "Netherlands", "teu": "15M" }
|
||||||
"teu": "37M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 3,
|
|
||||||
"port": "Ningbo-Zhoushan",
|
|
||||||
"country": "China",
|
|
||||||
"teu": "33M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 4,
|
|
||||||
"port": "Shenzhen",
|
|
||||||
"country": "China",
|
|
||||||
"teu": "29M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 5,
|
|
||||||
"port": "Guangzhou",
|
|
||||||
"country": "China",
|
|
||||||
"teu": "24M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 6,
|
|
||||||
"port": "Qingdao",
|
|
||||||
"country": "China",
|
|
||||||
"teu": "24M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 7,
|
|
||||||
"port": "Busan",
|
|
||||||
"country": "South Korea",
|
|
||||||
"teu": "22M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 8,
|
|
||||||
"port": "Tianjin",
|
|
||||||
"country": "China",
|
|
||||||
"teu": "21M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 9,
|
|
||||||
"port": "Dubai (Jebel Ali)",
|
|
||||||
"country": "UAE",
|
|
||||||
"teu": "15M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 10,
|
|
||||||
"port": "Rotterdam",
|
|
||||||
"country": "Netherlands",
|
|
||||||
"teu": "15M"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"hubGatewayTitle": "Hub vs Gateway",
|
"hubGatewayTitle": "Hub vs Gateway",
|
||||||
"hubTitle": "Hub Port",
|
"hubTitle": "Hub Port",
|
||||||
@ -2733,11 +2576,7 @@
|
|||||||
"description": "Empty weight of the container (shown on the door)",
|
"description": "Empty weight of the container (shown on the door)",
|
||||||
"example": "2,200 kg (20')"
|
"example": "2,200 kg (20')"
|
||||||
},
|
},
|
||||||
{
|
{ "element": "Cargo", "description": "Gross weight of all goods", "example": "Variable" },
|
||||||
"element": "Cargo",
|
|
||||||
"description": "Gross weight of all goods",
|
|
||||||
"example": "Variable"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"element": "Packaging",
|
"element": "Packaging",
|
||||||
"description": "Pallets, cartons, plastic film...",
|
"description": "Pallets, cartons, plastic film...",
|
||||||
@ -2803,22 +2642,10 @@
|
|||||||
"consequenceValue": "Re-weighing at shipper's expense, possible delay",
|
"consequenceValue": "Re-weighing at shipper's expense, possible delay",
|
||||||
"sanctionsTitle": "Sanctions by Region",
|
"sanctionsTitle": "Sanctions by Region",
|
||||||
"sanctions": [
|
"sanctions": [
|
||||||
{
|
{ "region": "France", "sanction": "Fine up to €7,500 and refusal to load" },
|
||||||
"region": "France",
|
{ "region": "USA", "sanction": "Refusal to load, fine from coast guard" },
|
||||||
"sanction": "Fine up to €7,500 and refusal to load"
|
{ "region": "China", "sanction": "Refusal to load, port penalties" },
|
||||||
},
|
{ "region": "European Union", "sanction": "Variable application by member state" }
|
||||||
{
|
|
||||||
"region": "USA",
|
|
||||||
"sanction": "Refusal to load, fine from coast guard"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"region": "China",
|
|
||||||
"sanction": "Refusal to load, port penalties"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"region": "European Union",
|
|
||||||
"sanction": "Variable application by member state"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"tipsTitle": "Best Practices",
|
"tipsTitle": "Best Practices",
|
||||||
"tips": [
|
"tips": [
|
||||||
@ -2913,41 +2740,13 @@
|
|||||||
"colTime": "Transit Time",
|
"colTime": "Transit Time",
|
||||||
"colVia": "Via",
|
"colVia": "Via",
|
||||||
"transitTimes": [
|
"transitTimes": [
|
||||||
{
|
{ "route": "Shanghai → Rotterdam", "time": "28–32 days", "via": "Suez" },
|
||||||
"route": "Shanghai → Rotterdam",
|
{ "route": "Shanghai → Le Havre", "time": "30–35 days", "via": "Suez" },
|
||||||
"time": "28–32 days",
|
{ "route": "Shanghai → Los Angeles", "time": "12–15 days", "via": "Direct Pacific" },
|
||||||
"via": "Suez"
|
{ "route": "Shanghai → New York", "time": "35–40 days", "via": "Suez or Panama" },
|
||||||
},
|
{ "route": "Rotterdam → New York", "time": "10–14 days", "via": "Direct Atlantic" },
|
||||||
{
|
{ "route": "Mumbai → Rotterdam", "time": "18–22 days", "via": "Suez" },
|
||||||
"route": "Shanghai → Le Havre",
|
{ "route": "Santos → Rotterdam", "time": "18–22 days", "via": "Direct Atlantic" }
|
||||||
"time": "30–35 days",
|
|
||||||
"via": "Suez"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Shanghai → Los Angeles",
|
|
||||||
"time": "12–15 days",
|
|
||||||
"via": "Direct Pacific"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Shanghai → New York",
|
|
||||||
"time": "35–40 days",
|
|
||||||
"via": "Suez or Panama"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Rotterdam → New York",
|
|
||||||
"time": "10–14 days",
|
|
||||||
"via": "Direct Atlantic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Mumbai → Rotterdam",
|
|
||||||
"time": "18–22 days",
|
|
||||||
"via": "Suez"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Santos → Rotterdam",
|
|
||||||
"time": "18–22 days",
|
|
||||||
"via": "Direct Atlantic"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"transitNote": "Note: These times are indicative and vary depending on rotations, transshipments and conditions.",
|
"transitNote": "Note: These times are indicative and vary depending on rotations, transshipments and conditions.",
|
||||||
"freeTimeTitle": "Free Time (Free Days)",
|
"freeTimeTitle": "Free Time (Free Days)",
|
||||||
|
|||||||
@ -25,29 +25,7 @@
|
|||||||
"discover": "Découvrir",
|
"discover": "Découvrir",
|
||||||
"tryNow": "Essayer maintenant",
|
"tryNow": "Essayer maintenant",
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"submit": "Envoyer",
|
"submit": "Envoyer"
|
||||||
"showPassword": "Afficher le mot de passe",
|
|
||||||
"hidePassword": "Masquer le mot de passe"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"title": "Espace administration",
|
|
||||||
"panelTitle": "Administration",
|
|
||||||
"backToApp": "Retour à l'application",
|
|
||||||
"logout": "Déconnexion",
|
|
||||||
"login": {
|
|
||||||
"badge": "Accès réservé",
|
|
||||||
"title": "Connexion administrateur",
|
|
||||||
"subtitle": "Accédez à l'espace d'administration Xpeditis",
|
|
||||||
"emailLabel": "Email",
|
|
||||||
"emailPlaceholder": "vous@entreprise.com",
|
|
||||||
"passwordLabel": "Mot de passe",
|
|
||||||
"passwordPlaceholder": "********",
|
|
||||||
"submit": "Se connecter",
|
|
||||||
"submitting": "Connexion...",
|
|
||||||
"notAdmin": "Accès refusé : ce compte n'est pas administrateur.",
|
|
||||||
"error": "Échec de la connexion. Vérifiez vos identifiants.",
|
|
||||||
"backToApp": "Retour au site"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Langue",
|
"label": "Langue",
|
||||||
@ -325,8 +303,7 @@
|
|||||||
"wiki": "Wiki Maritime",
|
"wiki": "Wiki Maritime",
|
||||||
"organization": "Organisation",
|
"organization": "Organisation",
|
||||||
"apiKeys": "Clés API",
|
"apiKeys": "Clés API",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs"
|
||||||
"admin": "Administration"
|
|
||||||
},
|
},
|
||||||
"topbar": {
|
"topbar": {
|
||||||
"defaultTitle": "Tableau de bord"
|
"defaultTitle": "Tableau de bord"
|
||||||
@ -1881,42 +1858,15 @@
|
|||||||
"colItem": "Poste",
|
"colItem": "Poste",
|
||||||
"colAmount": "Montant",
|
"colAmount": "Montant",
|
||||||
"exampleItems": [
|
"exampleItems": [
|
||||||
{
|
{ "item": "Fret maritime de base", "amount": "1 200 USD" },
|
||||||
"item": "Fret maritime de base",
|
{ "item": "BAF (Bunker)", "amount": "350 USD" },
|
||||||
"amount": "1 200 USD"
|
{ "item": "CAF (Devise)", "amount": "50 USD" },
|
||||||
},
|
{ "item": "THC Origine", "amount": "180 USD" },
|
||||||
{
|
{ "item": "THC Destination", "amount": "220 USD" },
|
||||||
"item": "BAF (Bunker)",
|
{ "item": "Frais B/L", "amount": "55 USD" },
|
||||||
"amount": "350 USD"
|
{ "item": "ISPS", "amount": "30 USD" },
|
||||||
},
|
{ "item": "Pré-acheminement", "amount": "250 USD" },
|
||||||
{
|
{ "item": "Total", "amount": "2 335 USD" }
|
||||||
"item": "CAF (Devise)",
|
|
||||||
"amount": "50 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "THC Origine",
|
|
||||||
"amount": "180 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "THC Destination",
|
|
||||||
"amount": "220 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "Frais B/L",
|
|
||||||
"amount": "55 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "ISPS",
|
|
||||||
"amount": "30 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "Pré-acheminement",
|
|
||||||
"amount": "250 USD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "Total",
|
|
||||||
"amount": "2 335 USD"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"conteneurs": {
|
"conteneurs": {
|
||||||
@ -2025,14 +1975,8 @@
|
|||||||
"condition": "Marchandises hors-gabarit / très lourdes",
|
"condition": "Marchandises hors-gabarit / très lourdes",
|
||||||
"recommendation": "Flat Rack ou Plateforme"
|
"recommendation": "Flat Rack ou Plateforme"
|
||||||
},
|
},
|
||||||
{
|
{ "condition": "Liquides en vrac", "recommendation": "ISO Tank" },
|
||||||
"condition": "Liquides en vrac",
|
{ "condition": "Volume < 15 m³", "recommendation": "Envisager le LCL" }
|
||||||
"recommendation": "ISO Tank"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"condition": "Volume < 15 m³",
|
|
||||||
"recommendation": "Envisager le LCL"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"documentsTransport": {
|
"documentsTransport": {
|
||||||
@ -2282,16 +2226,8 @@
|
|||||||
"description": "Matières toxiques et infectieuses",
|
"description": "Matières toxiques et infectieuses",
|
||||||
"subdivisions": ["6.1 Matières toxiques", "6.2 Matières infectieuses"]
|
"subdivisions": ["6.1 Matières toxiques", "6.2 Matières infectieuses"]
|
||||||
},
|
},
|
||||||
{
|
{ "class": "Classe 7", "name": "Radioactifs", "description": "Matières radioactives" },
|
||||||
"class": "Classe 7",
|
{ "class": "Classe 8", "name": "Corrosifs", "description": "Matières corrosives" },
|
||||||
"name": "Radioactifs",
|
|
||||||
"description": "Matières radioactives"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"class": "Classe 8",
|
|
||||||
"name": "Corrosifs",
|
|
||||||
"description": "Matières corrosives"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"class": "Classe 9",
|
"class": "Classe 9",
|
||||||
"name": "Divers",
|
"name": "Divers",
|
||||||
@ -2327,14 +2263,8 @@
|
|||||||
"group": "Groupe I (X)",
|
"group": "Groupe I (X)",
|
||||||
"description": "Grand danger — exigences d'emballage les plus strictes"
|
"description": "Grand danger — exigences d'emballage les plus strictes"
|
||||||
},
|
},
|
||||||
{
|
{ "group": "Groupe II (Y)", "description": "Danger moyen — emballage standard" },
|
||||||
"group": "Groupe II (Y)",
|
{ "group": "Groupe III (Z)", "description": "Faible danger — exigences moins strictes" }
|
||||||
"description": "Danger moyen — emballage standard"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "Groupe III (Z)",
|
|
||||||
"description": "Faible danger — exigences moins strictes"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"labelingTitle": "Étiquetage et Placardage",
|
"labelingTitle": "Étiquetage et Placardage",
|
||||||
"labelingContent": "Chaque colis doit afficher : numéro ONU, désignation officielle de transport, étiquettes de danger et classe. Les conteneurs doivent afficher des plaques-étiquettes de 250mm × 250mm correspondant à la classe IMDG. Les chargements mixtes requièrent des étiquettes pour chaque marchandise dangereuse.",
|
"labelingContent": "Chaque colis doit afficher : numéro ONU, désignation officielle de transport, étiquettes de danger et classe. Les conteneurs doivent afficher des plaques-étiquettes de 250mm × 250mm correspondant à la classe IMDG. Les chargements mixtes requièrent des étiquettes pour chaque marchandise dangereuse.",
|
||||||
@ -2498,10 +2428,7 @@
|
|||||||
"name": "Facture Commerciale",
|
"name": "Facture Commerciale",
|
||||||
"description": "En exacte conformité avec la L/C — montants, devises, description"
|
"description": "En exacte conformité avec la L/C — montants, devises, description"
|
||||||
},
|
},
|
||||||
{
|
{ "name": "Liste de Colisage", "description": "Cohérente avec la facture et le B/L" },
|
||||||
"name": "Liste de Colisage",
|
|
||||||
"description": "Cohérente avec la facture et le B/L"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Certificat d'Assurance",
|
"name": "Certificat d'Assurance",
|
||||||
"description": "Requis si CIF ou CIP — montants et couverture selon L/C"
|
"description": "Requis si CIF ou CIP — montants et couverture selon L/C"
|
||||||
@ -2556,10 +2483,7 @@
|
|||||||
"label": "Commission de confirmation",
|
"label": "Commission de confirmation",
|
||||||
"description": "0,2–0,5% par trimestre (banque confirmatrice)"
|
"description": "0,2–0,5% par trimestre (banque confirmatrice)"
|
||||||
},
|
},
|
||||||
{
|
{ "label": "Frais d'amendement", "description": "Frais fixes par amendement" },
|
||||||
"label": "Frais d'amendement",
|
|
||||||
"description": "Frais fixes par amendement"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"label": "Frais de réserve",
|
"label": "Frais de réserve",
|
||||||
"description": "Frais fixes en cas de réserve sur les documents"
|
"description": "Frais fixes en cas de réserve sur les documents"
|
||||||
@ -2642,66 +2566,16 @@
|
|||||||
"colCountry": "Pays",
|
"colCountry": "Pays",
|
||||||
"colTeu": "TEU / an",
|
"colTeu": "TEU / an",
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{ "rank": 1, "port": "Shanghai", "country": "Chine", "teu": "47M" },
|
||||||
"rank": 1,
|
{ "rank": 2, "port": "Singapour", "country": "Singapour", "teu": "37M" },
|
||||||
"port": "Shanghai",
|
{ "rank": 3, "port": "Ningbo-Zhoushan", "country": "Chine", "teu": "33M" },
|
||||||
"country": "Chine",
|
{ "rank": 4, "port": "Shenzhen", "country": "Chine", "teu": "29M" },
|
||||||
"teu": "47M"
|
{ "rank": 5, "port": "Guangzhou", "country": "Chine", "teu": "24M" },
|
||||||
},
|
{ "rank": 6, "port": "Qingdao", "country": "Chine", "teu": "24M" },
|
||||||
{
|
{ "rank": 7, "port": "Busan", "country": "Corée du Sud", "teu": "22M" },
|
||||||
"rank": 2,
|
{ "rank": 8, "port": "Tianjin", "country": "Chine", "teu": "21M" },
|
||||||
"port": "Singapour",
|
{ "rank": 9, "port": "Dubaï (Jebel Ali)", "country": "EAU", "teu": "15M" },
|
||||||
"country": "Singapour",
|
{ "rank": 10, "port": "Rotterdam", "country": "Pays-Bas", "teu": "15M" }
|
||||||
"teu": "37M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 3,
|
|
||||||
"port": "Ningbo-Zhoushan",
|
|
||||||
"country": "Chine",
|
|
||||||
"teu": "33M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 4,
|
|
||||||
"port": "Shenzhen",
|
|
||||||
"country": "Chine",
|
|
||||||
"teu": "29M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 5,
|
|
||||||
"port": "Guangzhou",
|
|
||||||
"country": "Chine",
|
|
||||||
"teu": "24M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 6,
|
|
||||||
"port": "Qingdao",
|
|
||||||
"country": "Chine",
|
|
||||||
"teu": "24M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 7,
|
|
||||||
"port": "Busan",
|
|
||||||
"country": "Corée du Sud",
|
|
||||||
"teu": "22M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 8,
|
|
||||||
"port": "Tianjin",
|
|
||||||
"country": "Chine",
|
|
||||||
"teu": "21M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 9,
|
|
||||||
"port": "Dubaï (Jebel Ali)",
|
|
||||||
"country": "EAU",
|
|
||||||
"teu": "15M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 10,
|
|
||||||
"port": "Rotterdam",
|
|
||||||
"country": "Pays-Bas",
|
|
||||||
"teu": "15M"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"hubGatewayTitle": "Hub vs Gateway",
|
"hubGatewayTitle": "Hub vs Gateway",
|
||||||
"hubTitle": "Port Hub",
|
"hubTitle": "Port Hub",
|
||||||
@ -2809,22 +2683,10 @@
|
|||||||
"consequenceValue": "Nouvelle pesée à la charge de l'expéditeur, retard possible",
|
"consequenceValue": "Nouvelle pesée à la charge de l'expéditeur, retard possible",
|
||||||
"sanctionsTitle": "Sanctions par Région",
|
"sanctionsTitle": "Sanctions par Région",
|
||||||
"sanctions": [
|
"sanctions": [
|
||||||
{
|
{ "region": "France", "sanction": "Amende jusqu'à 7 500€ et refus d'embarquement" },
|
||||||
"region": "France",
|
{ "region": "USA", "sanction": "Refus d'embarquement, amende par la garde côtière" },
|
||||||
"sanction": "Amende jusqu'à 7 500€ et refus d'embarquement"
|
{ "region": "Chine", "sanction": "Refus d'embarquement, pénalités portuaires" },
|
||||||
},
|
{ "region": "Union Européenne", "sanction": "Application variable selon pays membre" }
|
||||||
{
|
|
||||||
"region": "USA",
|
|
||||||
"sanction": "Refus d'embarquement, amende par la garde côtière"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"region": "Chine",
|
|
||||||
"sanction": "Refus d'embarquement, pénalités portuaires"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"region": "Union Européenne",
|
|
||||||
"sanction": "Application variable selon pays membre"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"tipsTitle": "Bonnes Pratiques",
|
"tipsTitle": "Bonnes Pratiques",
|
||||||
"tips": [
|
"tips": [
|
||||||
@ -2919,41 +2781,13 @@
|
|||||||
"colTime": "Transit Time",
|
"colTime": "Transit Time",
|
||||||
"colVia": "Via",
|
"colVia": "Via",
|
||||||
"transitTimes": [
|
"transitTimes": [
|
||||||
{
|
{ "route": "Shanghai → Rotterdam", "time": "28–32 jours", "via": "Suez" },
|
||||||
"route": "Shanghai → Rotterdam",
|
{ "route": "Shanghai → Le Havre", "time": "30–35 jours", "via": "Suez" },
|
||||||
"time": "28–32 jours",
|
{ "route": "Shanghai → Los Angeles", "time": "12–15 jours", "via": "Pacifique direct" },
|
||||||
"via": "Suez"
|
{ "route": "Shanghai → New York", "time": "35–40 jours", "via": "Suez ou Panama" },
|
||||||
},
|
{ "route": "Rotterdam → New York", "time": "10–14 jours", "via": "Atlantique direct" },
|
||||||
{
|
{ "route": "Mumbai → Rotterdam", "time": "18–22 jours", "via": "Suez" },
|
||||||
"route": "Shanghai → Le Havre",
|
{ "route": "Santos → Rotterdam", "time": "18–22 jours", "via": "Atlantique direct" }
|
||||||
"time": "30–35 jours",
|
|
||||||
"via": "Suez"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Shanghai → Los Angeles",
|
|
||||||
"time": "12–15 jours",
|
|
||||||
"via": "Pacifique direct"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Shanghai → New York",
|
|
||||||
"time": "35–40 jours",
|
|
||||||
"via": "Suez ou Panama"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Rotterdam → New York",
|
|
||||||
"time": "10–14 jours",
|
|
||||||
"via": "Atlantique direct"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Mumbai → Rotterdam",
|
|
||||||
"time": "18–22 jours",
|
|
||||||
"via": "Suez"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "Santos → Rotterdam",
|
|
||||||
"time": "18–22 jours",
|
|
||||||
"via": "Atlantique direct"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"transitNote": "Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.",
|
"transitNote": "Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.",
|
||||||
"freeTimeTitle": "Free Time (Jours Gratuits)",
|
"freeTimeTitle": "Free Time (Jours Gratuits)",
|
||||||
|
|||||||
@ -29,7 +29,6 @@ const prefixPublicPaths = [
|
|||||||
'/press',
|
'/press',
|
||||||
'/contact',
|
'/contact',
|
||||||
'/carrier',
|
'/carrier',
|
||||||
'/admin/login',
|
|
||||||
'/pricing',
|
'/pricing',
|
||||||
'/docs',
|
'/docs',
|
||||||
'/privacy',
|
'/privacy',
|
||||||
@ -72,11 +71,7 @@ export default function middleware(request: NextRequest) {
|
|||||||
const token = request.cookies.get('accessToken')?.value;
|
const token = request.cookies.get('accessToken')?.value;
|
||||||
|
|
||||||
if (!isPublicPath && !token) {
|
if (!isPublicPath && !token) {
|
||||||
// The admin area has its own entry point — send unauthenticated visitors
|
const loginUrl = new URL(`/${resolvedLocale}/login`, request.url);
|
||||||
// to the dedicated admin login instead of the regular one.
|
|
||||||
const isAdminArea = pathWithoutLocale === '/admin' || pathWithoutLocale.startsWith('/admin/');
|
|
||||||
const loginPath = isAdminArea ? '/admin/login' : '/login';
|
|
||||||
const loginUrl = new URL(`/${resolvedLocale}${loginPath}`, request.url);
|
|
||||||
loginUrl.searchParams.set('redirect', pathname);
|
loginUrl.searchParams.set('redirect', pathname);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
117
apps/frontend/src/components/admin/AdminPanelDropdown.tsx
Normal file
117
apps/frontend/src/components/admin/AdminPanelDropdown.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Link, usePathname } from '@/i18n/navigation';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Building2,
|
||||||
|
Package,
|
||||||
|
FileText,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
ScrollText,
|
||||||
|
Newspaper,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface AdminMenuItem {
|
||||||
|
key: string;
|
||||||
|
href: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminMenuItems: AdminMenuItem[] = [
|
||||||
|
{ key: 'users', href: '/dashboard/admin/users', icon: Users },
|
||||||
|
{ key: 'organizations', href: '/dashboard/admin/organizations', icon: Building2 },
|
||||||
|
{ key: 'bookings', href: '/dashboard/admin/bookings', icon: Package },
|
||||||
|
{ key: 'documents', href: '/dashboard/admin/documents', icon: FileText },
|
||||||
|
{ key: 'csvRates', href: '/dashboard/admin/csv-rates', icon: BarChart3 },
|
||||||
|
{ key: 'blog', href: '/dashboard/admin/blog', icon: Newspaper },
|
||||||
|
{ key: 'logs', href: '/dashboard/admin/logs', icon: ScrollText },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPanelDropdown() {
|
||||||
|
const t = useTranslations('components.adminPanelDropdown');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const isActiveRoute = adminMenuItems.some(item => pathname.startsWith(item.href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Trigger Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`flex items-center w-full px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
isActiveRoute ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings className="mr-3 h-5 w-5" />
|
||||||
|
<span className="flex-1 text-left">{t('trigger')}</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 right-0 mt-2 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
|
||||||
|
<div className="py-2">
|
||||||
|
{adminMenuItems.map(item => {
|
||||||
|
const isActive = pathname.startsWith(item.href);
|
||||||
|
const IconComponent = item.icon;
|
||||||
|
const name = t(`items.${item.key}` as any);
|
||||||
|
const description = t(`items.${item.key}Desc` as any);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-start px-4 py-3 hover:bg-gray-50 transition-colors ${
|
||||||
|
isActive ? 'bg-blue-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IconComponent className="h-5 w-5 mr-3 mt-0.5 text-gray-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier';
|
import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier';
|
||||||
|
|
||||||
interface CarrierFormProps {
|
interface CarrierFormProps {
|
||||||
@ -26,7 +25,6 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
|
|||||||
|
|
||||||
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 [showApiKey, setShowApiKey] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -131,24 +129,13 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
|
|||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
type="password"
|
||||||
type={showApiKey ? 'text' : 'password'}
|
value={formData.apiKey}
|
||||||
value={formData.apiKey}
|
onChange={e => setFormData({ ...formData, apiKey: e.target.value })}
|
||||||
onChange={e => setFormData({ ...formData, apiKey: e.target.value })}
|
placeholder="Enter API key"
|
||||||
placeholder="Enter API key"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
|
||||||
>
|
|
||||||
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rate Limit */}
|
{/* Rate Limit */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user