diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5615e88a7..5b1dd8d47 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,14 +33,15 @@ jobs: BUILD_ENV_FILE: ${{ matrix.build_env_file }} DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Sync environment from server + - name: Sync environment from Env Manager App run: | chmod +x scripts/deploy/*.sh - ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + ./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}" - name: Set compose project name run: | diff --git a/Dockerfile b/Dockerfile index dbab371f4..f891156c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,19 @@ FROM node:24-alpine AS deps WORKDIR /app -COPY package.json package-lock.json* ./ +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY local-packages/ ./local-packages/ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ - npm install --legacy-peer-deps + pnpm install --frozen-lockfile FROM deps AS base COPY . . FROM base AS portal-build -RUN npm run build:portal +RUN pnpm run build:portal FROM base AS backoffice-build -RUN npm run build:backoffice +RUN pnpm run build:backoffice FROM nginx:1.29-alpine AS portal COPY --from=portal-build /app/dist/apps/portal /usr/share/nginx/html diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx index 9d79075a7..d43ada55f 100644 --- a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx @@ -198,20 +198,21 @@ export type VerificationKind = 'medical' | 'sea-service'; */ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) { const { t } = useTranslation(); + const isMedical = kind === 'medical'; const [filter, setFilter] = useState('SUBMITTED'); const { data: pendingMedical, isLoading: loadingMedical, isFetching: fetchingMedical, refetch: refetchMedical, - } = useGetPendingMedicalQuery(filter); + } = useGetPendingMedicalQuery(filter, { skip: !isMedical }); const { data: pendingSeaService, isLoading: loadingSeaService, isFetching: fetchingSeaService, refetch: refetchSeaService, - } = useGetPendingSeaServiceQuery(filter); + } = useGetPendingSeaServiceQuery(filter, { skip: isMedical }); const [verifyMedical, { isLoading: rulingMedical }] = useVerifyMedicalCertificateMutation(); @@ -372,8 +373,6 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat [rulingSeaService, rule, verifySeaService, showDate, t], ); - const isMedical = kind === 'medical'; - return ( @@ -393,6 +392,8 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat )} </Text> + {statusFilter} + {isMedical ? ( <AdvancedTable columns={medicalTableColumns} @@ -408,7 +409,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat onPageSizeChange={handleMedicalPageSizeChange} refresh={refetchMedical} isLoading={loadingMedical || fetchingMedical} - emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')} + emptyText={emptyText} /> ) : ( <AdvancedTable @@ -425,7 +426,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat onPageSizeChange={handleSeaServicePageSizeChange} refresh={refetchSeaService} isLoading={loadingSeaService || fetchingSeaService} - emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')} + emptyText={emptyText} /> )} diff --git a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx index 1cffa5444..0a6afc228 100644 --- a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx @@ -31,7 +31,6 @@ import { IconLock, IconMail, IconMoon, - IconPhone, IconSettings, IconShieldLock, IconSun, @@ -42,7 +41,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; -import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui'; +import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; import { ActiveSessions, setUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth'; @@ -136,6 +135,9 @@ export function ProfilePage() { register: registerProfile, handleSubmit: handleProfileSubmit, reset: resetProfile, + watch: watchProfile, + setValue: setValueProfile, + trigger: triggerProfile, formState: { errors: profileErrors }, } = useForm<ProfileValues>({ resolver: zodResolver(profileSchema), @@ -372,11 +374,12 @@ export function ProfilePage() { error={profileErrors.email?.message} {...registerProfile('email')} /> - <TextInput + <PhoneInput label={t('profile.fields.phone')} - leftSection={<IconPhone size={18} />} + value={watchProfile('phoneNumber') || ''} + onChange={(val) => setValueProfile('phoneNumber', val, { shouldValidate: !!profileErrors.phoneNumber })} + onBlur={() => triggerProfile('phoneNumber')} error={profileErrors.phoneNumber?.message} - {...registerProfile('phoneNumber')} /> </SimpleGrid> </div> diff --git a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx index de599a69a..d65aaac45 100644 --- a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx +++ b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import { AppShell, Drawer } from '@mantine/core'; +import { AppShell, Box, Drawer, Group, Text } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -26,6 +26,14 @@ const BADGE_POLL_MS = 60_000; const HEADER_HEIGHT = 116; +/** + * Horizontal inset of the header chrome. `AppHeader` adds its own `px="lg"` + * inside this, so the nav strip below needs the sum to line up with the + * controls above it — it used to start 20px to their left. + */ +const CHROME_PAD_X = 32; +const NAV_STRIP_PAD_X = CHROME_PAD_X + 20; + /** * A desk left unlocked with a license-review or medical-record screen open is * the actual threat model here, not a slow token. 15 minutes of no mouse, @@ -144,7 +152,9 @@ export function BackofficeLayout() { return ( <AppShell - header={{ height: isSidebar ? 74 : HEADER_HEIGHT }} + // The top layout drops its nav strip on small screens — the drawer is + // the nav there — so the header shrinks back to a single row with it. + header={{ height: isSidebar ? 74 : { base: 74, sm: HEADER_HEIGHT } }} navbar={ isSidebar ? { @@ -162,13 +172,28 @@ export function BackofficeLayout() { <AppShell.Header style={{ background: "var(--mantine-color-body)", - borderBottom: "1px solid var(--mantine-color-gray-2)", + borderBottom: "1px solid var(--mantine-color-default-border)", display: "flex", flexDirection: "column", }} > - <div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}> + <div + style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }} + > <AppHeader + brand={ + isSidebar ? undefined : ( + <Group gap="xs" wrap="nowrap"> + <BrandMark size={28} /> + <Text fw={700} size="sm" lh={1.1} visibleFrom="xs"> + {t('app.name')} + </Text> + </Group> + ) + } + // Nothing to toggle on a desktop top bar; on mobile it opens the + // drawer below. + burgerHiddenFrom={isSidebar ? undefined : 'sm'} onToggleNav={toggleNav} onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav} navOpened={opened} @@ -182,13 +207,14 @@ export function BackofficeLayout() { </div> {!isSidebar && ( - <div + <Box + visibleFrom="sm" style={{ display: 'flex', alignItems: 'center', - padding: '0 32px', + padding: `0 ${NAV_STRIP_PAD_X}px`, height: 42, - borderTop: '1px solid var(--mantine-color-gray-1)', + borderTop: '1px solid var(--mantine-color-default-border)', flexShrink: 0, }} > @@ -199,7 +225,7 @@ export function BackofficeLayout() { activePath={location.pathname} onNavigate={go} /> - </div> + </Box> )} </AppShell.Header> @@ -210,7 +236,7 @@ export function BackofficeLayout() { overflow: "hidden", transition: "width 200ms ease", background: "var(--mantine-color-body)", - borderRight: "1px solid var(--mantine-color-gray-2)", + borderRight: "1px solid var(--mantine-color-default-border)", }} > <AppSidebar @@ -238,30 +264,28 @@ export function BackofficeLayout() { {/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside click) instead of AppShell's full-width mobile navbar. Mirrors the landing page's mobile menu. */} - {isSidebar && ( - <Drawer - opened={opened} - onClose={closeNav} - hiddenFrom="sm" - size="75%" - padding={0} - withCloseButton={false} - > - <AppSidebar - navItems={sections} - collapsed={false} - activePath={location.pathname} - onToggleCollapse={handleToggleCollapse} - onNavigate={(item) => { - go(item); - closeNav(); - }} - brandName={t('app.name')} - brandSubtitle={t('app.authority')} - brandLogo={<BrandMark size={32} />} - /> - </Drawer> - )} + <Drawer + opened={opened} + onClose={closeNav} + hiddenFrom="sm" + size="75%" + padding={0} + withCloseButton={false} + > + <AppSidebar + navItems={sections} + collapsed={false} + activePath={location.pathname} + onToggleCollapse={handleToggleCollapse} + onNavigate={(item) => { + go(item); + closeNav(); + }} + brandName={t('app.name')} + brandSubtitle={t('app.authority')} + brandLogo={<BrandMark size={32} />} + /> + </Drawer> </AppShell> ); } diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index 73e924775..c15fa983b 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -30,7 +30,12 @@ import { IconShieldCheck, } from '@tabler/icons-react'; import { authStorage, useCurrentProfile } from '@ema-platform/auth'; -import { useApiQuery } from '@ema-platform/api'; +import { + extractErrorMessage, + useApiQuery, + useBypassPaymentMutation, + useGetPaymentCapabilitiesQuery, +} from '@ema-platform/api'; import { useGetMySeaServiceRecordsQuery, useGetMyMedicalCertificatesQuery, @@ -146,11 +151,32 @@ export function CertificatesPage() { const [previewTitle, setPreviewTitle] = useState(''); const [loading, setLoading] = useState(false); const { pay, isPaying } = useApplicationPayment(); + // Dev/test only — the API reports false in production and the button is + // never rendered. Same shortcut My Applications offers. + const { data: capabilities } = useGetPaymentCapabilitiesQuery(); + const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation(); - const { data } = useApiQuery<CertificatesOverview>({ + const { data, refetch } = useApiQuery<CertificatesOverview>({ url: '/certificates/my', method: 'GET', }); + + const handleBypass = async (applicationId: string) => { + try { + const result = await bypassPayment(applicationId).unwrap(); + notifications.show({ + color: 'teal', + title: 'Payment bypassed', + message: result.certificateIssued + ? 'The certificate has been issued.' + : `Application is now ${humanStatus(result.status)}.`, + }); + // Generic query, not tag-driven: refresh it by hand. + refetch(); + } catch (err) { + notifications.show({ color: 'red', title: 'Bypass failed', message: extractErrorMessage(err) }); + } + }; const certificates = data?.certificates ?? []; const applications = data?.applications ?? []; @@ -331,6 +357,17 @@ export function CertificatesPage() { Pay {app.feeAmount.toLocaleString()} {app.feeCurrency} </Button> )} + {app.feeAmount !== null && capabilities?.bypassEnabled && ( + <Button + size="xs" + variant="default" + loading={bypassing} + onClick={() => handleBypass(app.applicationId)} + title="Testing only — marks the fee paid without a provider" + > + Bypass payment + </Button> + )} <Text fz="xs" c="blue" diff --git a/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx b/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx index 01e419ddc..bdf96eef2 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx @@ -164,6 +164,13 @@ function EvidenceField({ // ---------------------------------------------------------------- sea service +/** Today as a `yyyy-mm-dd` key — same shape the pickers emit, so plain + * string comparison is a valid date comparison. Taken in the authority's + * timezone, matching the server's check, so a seafarer logging in from a + * zone ahead of Addis isn't offered a day the server then rejects. */ +const todayKey = () => + new Date().toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); + const EMPTY_SEA_SERVICE = { vesselName: '', imoNumber: '', @@ -276,12 +283,27 @@ function SeaServiceTab() { } }; + // Service already served — neither end of an engagement can be in the future. + const today = todayKey(); + const dateError = + form.engagementDate > today || form.dischargeDate > today + ? t('seaRecords.seaService.dateFuture', { + defaultValue: 'Engagement and discharge dates cannot be in the future.', + }) + : form.engagementDate && + form.dischargeDate && + form.engagementDate >= form.dischargeDate + ? t('seaRecords.seaService.dateOrder', { + defaultValue: 'Discharge date must be after the engagement date.', + }) + : null; + const valid = form.vesselName.trim().length > 1 && form.rank.trim().length > 1 && form.engagementDate && form.dischargeDate && - form.engagementDate < form.dischargeDate; + !dateError; // Shown under the date pickers as they are filled: the seafarer sees what // the engagement is worth before saving it. @@ -408,6 +430,7 @@ function SeaServiceTab() { onChange={(val) => setForm({ ...form, engagementDate: val }) } + maxDate={form.dischargeDate || today} dateFormat="date" /> <AmharicDatePicker @@ -417,24 +440,23 @@ function SeaServiceTab() { onChange={(val) => setForm({ ...form, dischargeDate: val }) } + minDate={form.engagementDate || undefined} + maxDate={today} dateFormat="date" /> </Group> - {form.engagementDate && form.dischargeDate && ( + {(dateError || (form.engagementDate && form.dischargeDate)) && ( <Alert variant="light" - color={formDays === null ? 'red' : 'teal'} + color={dateError ? 'red' : 'teal'} icon={<IconInfoCircle size={16} />} py={6} > - {formDays === null - ? t('seaRecords.seaService.dateOrder', { - defaultValue: 'Discharge date must be after the engagement date.', - }) - : t('seaRecords.seaService.daysServed', { - days: formDays, - defaultValue: 'Days served on this engagement: {{days}} (both days counted)', - })} + {dateError ?? + t('seaRecords.seaService.daysServed', { + days: formDays, + defaultValue: 'Days served on this engagement: {{days}} (both days counted)', + })} </Alert> )} <Textarea @@ -581,10 +603,12 @@ function MedicalTab() { } }; + const today = todayKey(); const valid = form.issuerName.trim().length > 1 && form.issueDate && form.expiryDate && + form.issueDate <= today && form.issueDate < form.expiryDate; const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 }); @@ -664,6 +688,7 @@ function MedicalTab() { required value={form.issueDate} onChange={(val) => setForm({ ...form, issueDate: val })} + maxDate={today} dateFormat="date" /> <AmharicDatePicker @@ -671,6 +696,7 @@ function MedicalTab() { required value={form.expiryDate} onChange={(val) => setForm({ ...form, expiryDate: val })} + minDate={form.issueDate || undefined} dateFormat="date" /> </Group> diff --git a/libs/ui/src/lib/input/AmharicDatePicker.tsx b/libs/ui/src/lib/input/AmharicDatePicker.tsx index 78919ee2a..5d18ada87 100644 --- a/libs/ui/src/lib/input/AmharicDatePicker.tsx +++ b/libs/ui/src/lib/input/AmharicDatePicker.tsx @@ -15,7 +15,7 @@ import { import { TimeInput } from '@mantine/dates'; import { useDisclosure } from '@mantine/hooks'; import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic'; -import { DayPicker as GregorianDayPicker } from '@daypicker/react'; +import { DayPicker as GregorianDayPicker, type Matcher } from '@daypicker/react'; import { IconCalendarEvent } from '@tabler/icons-react'; import '@daypicker/react/dist/style.css'; import './AmharicDatePicker.css'; @@ -131,6 +131,11 @@ export interface AmharicDatePickerProps { /** Show a time-of-day field alongside the calendar. Off by default — * most callers only need a calendar day. */ withTime?: boolean; + /** Earliest/latest selectable day. Accepts a Date or a value in the same + * wire format as `value`. Days outside the range are disabled in both + * calendars. */ + minDate?: Date | string; + maxDate?: Date | string; /** Wire format for `value`/`onChange`: a full ISO-8601 instant (default, * what most backend date fields expect) or a bare `yyyy-mm-dd` calendar * date (what filter query params and plain `date: string` DTO fields @@ -151,6 +156,8 @@ export function AmharicDatePicker({ onBlur, w, withTime = false, + minDate, + maxDate, dateFormat = 'iso', }: AmharicDatePickerProps) { const { t, i18n } = useTranslation(); @@ -161,6 +168,21 @@ export function AmharicDatePicker({ const selected = parseWireValue(value, dateFormat, withTime); + const asDate = (limit: Date | string | undefined) => + limit instanceof Date ? limit : parseWireValue(limit, dateFormat, withTime); + const min = asDate(minDate); + const max = asDate(maxDate); + const outOfRange: Matcher[] = [ + ...(min ? [{ before: min }] : []), + ...(max ? [{ after: max }] : []), + ]; + // Compared as calendar days — the limits carry a midnight time-of-day, so + // an instant comparison would call today "after" a max of today. + const todayKey = formatPlainDate(new Date()); + const todayOutOfRange = + (!!min && todayKey < formatPlainDate(min)) || + (!!max && todayKey > formatPlainDate(max)); + const dateLabel = selected ? calendarType === 'EN' ? selected.toLocaleDateString('en-US', { @@ -266,6 +288,7 @@ export function AmharicDatePicker({ endMonth={YEAR_DROPDOWN_END} numerals="latn" captionLayout="dropdown" + disabled={outOfRange} formatters={ETH_FORMATTERS} onSelect={(date: Date | undefined) => { onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : ''); @@ -281,6 +304,7 @@ export function AmharicDatePicker({ startMonth={YEAR_DROPDOWN_START} endMonth={YEAR_DROPDOWN_END} captionLayout="dropdown" + disabled={outOfRange} onSelect={(date: Date | undefined) => { onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : ''); if (!withTime) close(); @@ -389,6 +413,7 @@ export function AmharicDatePicker({ <Button variant="light" size="xs" + disabled={todayOutOfRange} onClick={() => { onChange?.(formatWireValue(new Date(), dateFormat, withTime)); close(); diff --git a/libs/ui/src/lib/layout/AppHeader.tsx b/libs/ui/src/lib/layout/AppHeader.tsx index a7e14a148..0e5755955 100644 --- a/libs/ui/src/lib/layout/AppHeader.tsx +++ b/libs/ui/src/lib/layout/AppHeader.tsx @@ -14,6 +14,7 @@ import { IconLogout, IconUserCircle, } from '@tabler/icons-react'; +import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { LanguageSwitcher } from './LanguageSwitcher'; import { ColorSchemeToggle } from './ColorSchemeToggle'; @@ -36,6 +37,17 @@ interface AppHeaderProps { supportedLanguages: readonly string[]; onNotificationsClick?: () => void; notificationCount?: number; + /** + * Rendered at the far left. The sidebar layout carries the brand in the + * sidebar itself; the top-bar layout has no sidebar, so it passes the brand + * here rather than leaving the chrome unbranded. + */ + brand?: ReactNode; + /** + * Breakpoint from which the burger is hidden. The top-bar layout only needs + * it on small screens, where the drawer replaces the nav strip. + */ + burgerHiddenFrom?: string; } export function AppHeader({ @@ -50,17 +62,21 @@ export function AppHeader({ supportedLanguages, onNotificationsClick, notificationCount, + brand, + burgerHiddenFrom, }: AppHeaderProps) { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; return ( <Group h="100%" px="lg" justify="space-between" wrap="nowrap"> <Group gap="md" wrap="nowrap"> + {brand} {/* Hamburger — styled like user-management Top.tsx */} {/* The Burger itself owns the click so the control is a real, keyboard reachable <button>; the Box is chrome only. It previously wrapped a no-op button, which no keyboard user could operate. */} <Box + hiddenFrom={burgerHiddenFrom} style={{ display: 'flex', alignItems: 'center', diff --git a/libs/ui/src/lib/layout/AppTopNav.tsx b/libs/ui/src/lib/layout/AppTopNav.tsx index 84e6d63d8..99345ab14 100644 --- a/libs/ui/src/lib/layout/AppTopNav.tsx +++ b/libs/ui/src/lib/layout/AppTopNav.tsx @@ -1,4 +1,5 @@ import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core'; +import { forwardRef } from 'react'; import { IconChevronDown } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import type { NavItem } from './AppSidebar'; @@ -23,7 +24,9 @@ interface AppTopNavProps { * scrolling strip, so with twenty-odd of them most were off-screen and the * grouping that the sidebar already had was thrown away. Here each section * collapses to a single labelled dropdown, which fits and keeps the same - * information architecture as the sidebar. + * information architecture as the sidebar. Sections that still do not fit + * scroll horizontally rather than dropping off the edge — under ~1100px the + * last one or two were simply unreachable. */ export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) { const { t } = useTranslation(); @@ -36,6 +39,7 @@ export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) wrap="nowrap" role="navigation" aria-label={t('nav.primary', 'Primary')} + style={{ flex: 1, minWidth: 0, overflowX: 'auto', scrollbarWidth: 'none' }} > {sections.map((section, index) => { // An unlabelled leading block (Dashboard) is a plain link, not a menu. @@ -148,40 +152,52 @@ interface TopNavButtonProps { onClick?: () => void; } -function TopNavButton({ - label, - active, - badge, - soon, - withChevron, - onClick, -}: TopNavButtonProps) { - return ( - <UnstyledButton - onClick={onClick} - style={{ - display: 'flex', - alignItems: 'center', - gap: rem(6), - padding: `0 ${rem(14)}`, - height: '100%', - borderBottom: '2px solid', - borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent', - color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)', - fontWeight: active ? 600 : 500, - fontSize: rem(14), - whiteSpace: 'nowrap', - opacity: soon ? 0.55 : 1, - marginBottom: -1, - }} - > - <span>{label}</span> - {badge !== null && badge !== undefined && ( - <Badge size="xs" variant="filled" color="red" radius="sm"> - {badge} - </Badge> - )} - {withChevron && <IconChevronDown size={14} stroke={2} />} - </UnstyledButton> - ); -} +/** + * `Menu.Target` positions its dropdown against the ref it passes to its child, + * so a plain function component here left every section menu anchored at the + * top-left of the viewport, covering the header instead of opening under the + * button that was clicked. The rest props carry Menu's own click and aria + * handling onto the real button. + */ +const TopNavButton = forwardRef<HTMLButtonElement, TopNavButtonProps>( + function TopNavButton( + { label, active, badge, soon, withChevron, onClick, ...others }, + ref, + ) { + return ( + <UnstyledButton + ref={ref} + onClick={onClick} + {...others} + style={{ + display: 'flex', + alignItems: 'center', + gap: rem(6), + padding: `0 ${rem(14)}`, + height: '100%', + flexShrink: 0, + borderBottom: '2px solid', + borderBottomColor: active + ? 'var(--mantine-color-blue-6)' + : 'transparent', + color: active + ? 'var(--mantine-color-blue-6)' + : 'var(--mantine-color-gray-6)', + fontWeight: active ? 600 : 500, + fontSize: rem(14), + whiteSpace: 'nowrap', + opacity: soon ? 0.55 : 1, + marginBottom: -1, + }} + > + <span>{label}</span> + {badge !== null && badge !== undefined && ( + <Badge size="xs" variant="filled" color="red" radius="sm"> + {badge} + </Badge> + )} + {withChevron && <IconChevronDown size={14} stroke={2} />} + </UnstyledButton> + ); + }, +); diff --git a/package-lock.json b/package-lock.json index 8c1da012f..a751443bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "i18n-nationality": "^1.4.0", "i18next": "^25.6.0", "js-cookie": "^3.0.8", + "libphonenumber-js": "^1.13.11", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.71.2", @@ -44,6 +45,8 @@ "zod": "^4.3.6" }, "devDependencies": { + "@eslint/eslintrc": "3.3.6", + "@eslint/js": "^10.0.1", "@nx/eslint": "^22.5.4", "@nx/eslint-plugin": "^22.5.4", "@nx/react": "^22.5.4", @@ -2799,16 +2802,24 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { @@ -11484,6 +11495,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/eslint/node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -13403,6 +13427,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.11", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.11.tgz", + "integrity": "sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ace04a986..c564afae6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +packages: + - 'apps/*' + - 'libs/*' + allowBuilds: canvas: set this to true or false core-js: set this to true or false diff --git a/scripts/deploy/sync-env-from-env-manager.sh b/scripts/deploy/sync-env-from-env-manager.sh new file mode 100644 index 000000000..4a657df1c --- /dev/null +++ b/scripts/deploy/sync-env-from-env-manager.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Sync .env files from the Env Manager API into the repo. +# +# Usage: +# ENV_MANAGER_TOKEN=xxx ./scripts/deploy/sync-env-from-server.sh freight-api freight-portal freight-backoffice +# +# You normally only need to pass the token via the action secret, and BRANCH +# via the job-level env (e.g. `BRANCH: ${{ github.ref_name }}` in the workflow): +# env: +# BRANCH: ${{ github.ref_name }} +# ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} +# +# API layout (one endpoint per service): +# GET https://env.smart.aaca.gov.et/api/env/edr/<branch>/<service>?format=dotenv +# Header: Authorization: Bearer <ENV_MANAGER_TOKEN> +set -euo pipefail + +PROJECT="ema" +ENV_MANAGER_URL="https://env.smart.aaca.gov.et" +BRANCH="${BRANCH:?BRANCH is required}" +ENV_MANAGER_TOKEN="${ENV_MANAGER_TOKEN:?ENV_MANAGER_TOKEN is required}" + +declare -A SERVICE_ENV_TARGET=( + ["ema_portal"]="apps/portal/.env" + ["ema_backoffice"]="apps/backoffice/.env" +) + +for service in "$@"; do + branch_api_name="${BRANCH//-/_}" + service_api_name="${service//-/_}" + dest="${SERVICE_ENV_TARGET[${service_api_name}]:-}" + if [[ -z "${dest}" ]]; then + echo "Unknown service: ${service}" >&2 + exit 1 + fi + + url="${ENV_MANAGER_URL}/api/env/${PROJECT}/${branch_api_name}/${service_api_name}?format=dotenv" + mkdir -p "$(dirname "${dest}")" + + tmp_file="$(mktemp)" + trap 'rm -f "${tmp_file}"' RETURN 2>/dev/null || true + + http_status=$(curl -fsS -o "${tmp_file}" -w "%{http_code}" \ + -H "Authorization: Bearer ${ENV_MANAGER_TOKEN}" \ + "${url}") || { + echo "Failed to fetch env for '${service}' from ${url}" >&2 + rm -f "${tmp_file}" + exit 1 + } + + if [[ "${http_status}" != "200" ]]; then + echo "Env Manager returned HTTP ${http_status} for '${service}' (${url})" >&2 + rm -f "${tmp_file}" + exit 1 + fi + + if [[ ! -s "${tmp_file}" ]]; then + echo "Env Manager returned an empty response for '${service}' (${url})" >&2 + rm -f "${tmp_file}" + exit 1 + fi + + mv "${tmp_file}" "${dest}" + echo "Synced ${url} -> ${dest}" + + port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${dest}" | head -n1 | tr -d '[:space:]') + if [[ -z "${port_value}" ]]; then + echo "Missing required PORT in env file for '${service}' (${dest})" >&2 + exit 1 + fi + + if [[ -n "${GITHUB_ENV:-}" ]]; then + service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_') + echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}" + echo "Exported ${service_var}_PORT from ${dest}" + # Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args. + grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${dest}" \ + | sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true + fi +done