mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
user management ui
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
type CookieOptions = NonNullable<Parameters<typeof Cookies.set>[2]>;
|
||||
|
||||
const REMEMBER_ME_STORAGE_KEY = "rememberMe";
|
||||
|
||||
const parseRememberMe = (value: string | null) => {
|
||||
if (!value) return false;
|
||||
|
||||
try {
|
||||
return JSON.parse(value) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRememberMePreference = () =>
|
||||
parseRememberMe(localStorage.getItem(REMEMBER_ME_STORAGE_KEY));
|
||||
|
||||
export const persistRememberMePreference = (rememberMe: boolean) => {
|
||||
localStorage.setItem(REMEMBER_ME_STORAGE_KEY, JSON.stringify(rememberMe));
|
||||
};
|
||||
|
||||
export const clearRememberMePreference = () => {
|
||||
localStorage.removeItem(REMEMBER_ME_STORAGE_KEY);
|
||||
};
|
||||
|
||||
export const getAuthCookieOptions = (
|
||||
rememberMe: boolean,
|
||||
): CookieOptions => ({
|
||||
...(rememberMe ? { expires: 7 } : {}),
|
||||
secure: window.location.protocol === "https:",
|
||||
sameSite: "Strict",
|
||||
});
|
||||
|
||||
export const setAuthCookies = ({
|
||||
token,
|
||||
refreshToken,
|
||||
rememberMe,
|
||||
}: {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
rememberMe: boolean;
|
||||
}) => {
|
||||
const cookieOptions = getAuthCookieOptions(rememberMe);
|
||||
|
||||
Cookies.set("auth-token", token, cookieOptions);
|
||||
Cookies.set("refresh-token", refreshToken, cookieOptions);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Shared utilities for exporting dashboard/report charts via html2canvas.
|
||||
*
|
||||
* Problem: html2canvas serializes SVG elements to a data URL before drawing
|
||||
* them on canvas. Inside that data URL the parent document's CSS is not
|
||||
* available, so `fill="var(--primary)"` renders as nothing / black.
|
||||
* Additionally, html2canvas's internal CSS parser cannot handle oklch() color
|
||||
* values used by Tailwind v4.
|
||||
*
|
||||
* Solution:
|
||||
* 1. Patch oklch() in <style> tags and inject :root overrides so html2canvas's
|
||||
* CSS parser sees rgb() values everywhere.
|
||||
* 2. Resolve CSS variable references in SVG presentation attributes
|
||||
* (fill, stroke, stop-color) to their computed rgb() values before capture,
|
||||
* then restore them afterward.
|
||||
*/
|
||||
|
||||
// ── Oklch → RGB conversion ────────────────────────────────────────────────────
|
||||
|
||||
export function oklchToRgb(l: number, c: number, h: number): [number, number, number] {
|
||||
const hr = (h * Math.PI) / 180;
|
||||
const a = c * Math.cos(hr);
|
||||
const b = c * Math.sin(hr);
|
||||
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
|
||||
const lc = l_ ** 3, mc = m_ ** 3, sc = s_ ** 3;
|
||||
const gam = (x: number) =>
|
||||
x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
|
||||
const clamp = (x: number) => Math.max(0, Math.min(255, Math.round(x * 255)));
|
||||
return [
|
||||
clamp(gam(+4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc)),
|
||||
clamp(gam(-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc)),
|
||||
clamp(gam(-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc)),
|
||||
];
|
||||
}
|
||||
|
||||
export const OKLCH_RE = /oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)[^)]*\)/g;
|
||||
|
||||
export function oklchMatchToRgb(_: string, l: string, c: string, h: string): string {
|
||||
const lv = l.endsWith("%") ? parseFloat(l) / 100 : parseFloat(l);
|
||||
const [r, g, b] = oklchToRgb(lv, parseFloat(c), parseFloat(h));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
// ── CSS oklch patching ────────────────────────────────────────────────────────
|
||||
|
||||
function collectOklchOverrides(
|
||||
rules: CSSRuleList,
|
||||
overrides: Map<string, string>,
|
||||
): void {
|
||||
for (const rule of Array.from(rules)) {
|
||||
if (rule instanceof CSSStyleRule) {
|
||||
for (let i = 0; i < rule.style.length; i++) {
|
||||
const prop = rule.style[i];
|
||||
const val = rule.style.getPropertyValue(prop).trim();
|
||||
if (!val.includes("oklch") || overrides.has(prop)) continue;
|
||||
const m = val.match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/);
|
||||
if (m) {
|
||||
const lv = m[1].endsWith("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
||||
const [r, g, b] = oklchToRgb(lv, parseFloat(m[2]), parseFloat(m[3]));
|
||||
overrides.set(prop, `rgb(${r},${g},${b})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("cssRules" in rule && (rule as CSSGroupingRule).cssRules) {
|
||||
collectOklchOverrides((rule as CSSGroupingRule).cssRules, overrides);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches oklch() values in inline <style> tags and injects a :root override
|
||||
* block for external stylesheets so html2canvas's CSS parser sees only rgb().
|
||||
* Returns a `restore()` function that undoes all patches.
|
||||
*/
|
||||
export function patchDocumentOklch(): { restore: () => void } {
|
||||
const patched = new Map<HTMLStyleElement, string>();
|
||||
for (const el of Array.from(document.querySelectorAll("style"))) {
|
||||
const s = el as HTMLStyleElement;
|
||||
if (s.textContent?.includes("oklch")) {
|
||||
patched.set(s, s.textContent);
|
||||
s.textContent = s.textContent.replace(OKLCH_RE, oklchMatchToRgb);
|
||||
}
|
||||
}
|
||||
|
||||
const overrideVars = new Map<string, string>();
|
||||
for (const sheet of Array.from(document.styleSheets)) {
|
||||
try { collectOklchOverrides(sheet.cssRules, overrideVars); } catch {}
|
||||
}
|
||||
|
||||
let varOverride: HTMLStyleElement | null = null;
|
||||
if (overrideVars.size > 0) {
|
||||
varOverride = document.createElement("style");
|
||||
varOverride.textContent = `:root { ${
|
||||
Array.from(overrideVars.entries())
|
||||
.map(([p, v]) => `${p}: ${v}`)
|
||||
.join("; ")
|
||||
} }`;
|
||||
document.head.appendChild(varOverride);
|
||||
}
|
||||
|
||||
return {
|
||||
restore() {
|
||||
for (const [s, orig] of patched.entries()) s.textContent = orig;
|
||||
varOverride?.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── SVG presentation attribute inlining ──────────────────────────────────────
|
||||
|
||||
const SVG_PRES_ATTRS = ["fill", "stroke", "stop-color"] as const;
|
||||
|
||||
/**
|
||||
* Walks every SVG descendant of `root` and replaces any presentation attribute
|
||||
* that contains a CSS variable (`var(…)`) with its computed rgb() value.
|
||||
*
|
||||
* This must be called AFTER patchDocumentOklch() so that getComputedStyle
|
||||
* already returns rgb values rather than oklch.
|
||||
*
|
||||
* Returns a `restore()` function that reverts all attribute changes.
|
||||
* For off-screen clones that are discarded after capture, calling restore()
|
||||
* is optional.
|
||||
*/
|
||||
export function inlineSvgPresentationAttrs(
|
||||
root: Element,
|
||||
): { restore: () => void } {
|
||||
const patches: Array<{ el: SVGElement; attr: string; orig: string }> = [];
|
||||
|
||||
for (const node of Array.from(root.querySelectorAll("*"))) {
|
||||
if (!(node instanceof SVGElement)) continue;
|
||||
const cs = window.getComputedStyle(node);
|
||||
for (const attr of SVG_PRES_ATTRS) {
|
||||
const val = node.getAttribute(attr);
|
||||
if (!val || !val.includes("var(")) continue;
|
||||
const resolved = cs.getPropertyValue(attr).trim().replace(OKLCH_RE, oklchMatchToRgb);
|
||||
if (!resolved || resolved === val) continue;
|
||||
patches.push({ el: node, attr, orig: val });
|
||||
node.setAttribute(attr, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
restore() {
|
||||
for (const { el, attr, orig } of patches) el.setAttribute(attr, orig);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that runs the full pre-capture patch sequence on a DOM
|
||||
* element:
|
||||
* 1. patchDocumentOklch – fixes oklch in <style>/<link> CSS
|
||||
* 2. inlineSvgPresentationAttrs – resolves var() in SVG fill/stroke attrs
|
||||
*
|
||||
* Returns a single `restore()` that undoes both steps in reverse order.
|
||||
*
|
||||
* Usage:
|
||||
* const patch = prepareForHtml2Canvas(element);
|
||||
* try { await html2canvas(element, …); } finally { patch.restore(); }
|
||||
*/
|
||||
export function prepareForHtml2Canvas(root: Element): { restore: () => void } {
|
||||
const oklch = patchDocumentOklch();
|
||||
const svg = inlineSvgPresentationAttrs(root);
|
||||
return {
|
||||
restore() {
|
||||
svg.restore();
|
||||
oklch.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { VisibilityState } from "@tanstack/react-table";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "table-column-visibility:";
|
||||
|
||||
const getStorageKey = (tableName: string) =>
|
||||
`${STORAGE_KEY_PREFIX}${tableName}`;
|
||||
|
||||
/**
|
||||
* Reads/writes column visibility per table. Backed by localStorage for now;
|
||||
* swap the implementations here for backend calls later without touching callers.
|
||||
*/
|
||||
export const getColumnVisibility = (
|
||||
tableName: string,
|
||||
): VisibilityState | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey(tableName));
|
||||
return raw ? (JSON.parse(raw) as VisibilityState) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setColumnVisibility = (
|
||||
tableName: string,
|
||||
visibility: VisibilityState,
|
||||
): void => {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(tableName), JSON.stringify(visibility));
|
||||
} catch {
|
||||
// ignore write errors (e.g. storage unavailable)
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { MeDto } from "@/shared/dto/user/meDto";
|
||||
import {
|
||||
getProfile,
|
||||
RegisterWithFaydaResponse,
|
||||
} from "@/shared/services/authService";
|
||||
import {
|
||||
getAuthCookieOptions,
|
||||
setAuthCookies,
|
||||
} from "@/shared/utils/authPersistence";
|
||||
import type { VerifiedCitizen } from "@/complaints/types/complaint.types";
|
||||
|
||||
function unwrapApiData<T>(payload: T | { data?: T }): T {
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"data" in payload &&
|
||||
payload.data &&
|
||||
typeof payload.data === "object"
|
||||
) {
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function resolveLocalizedName(
|
||||
name: string | { am?: string; en?: string } | undefined,
|
||||
preferredLanguage = "en",
|
||||
): string {
|
||||
if (!name) return "";
|
||||
if (typeof name === "string") return name.trim();
|
||||
if (preferredLanguage === "am") {
|
||||
return (name.am || name.en || "").trim();
|
||||
}
|
||||
return (name.en || name.am || "").trim();
|
||||
}
|
||||
|
||||
function resolveProfileDisplayName(
|
||||
profile: MeDto | null | undefined,
|
||||
preferredLanguage = "en",
|
||||
): string {
|
||||
if (!profile) return "";
|
||||
|
||||
const extendedProfile = profile as MeDto & {
|
||||
fullName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
faydaId?: string;
|
||||
};
|
||||
|
||||
const localizedName = resolveLocalizedName(profile.name, preferredLanguage);
|
||||
if (localizedName) return localizedName;
|
||||
|
||||
const combinedName = [extendedProfile.firstName, extendedProfile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (combinedName) return combinedName;
|
||||
|
||||
if (extendedProfile.fullName?.trim()) {
|
||||
return extendedProfile.fullName.trim();
|
||||
}
|
||||
|
||||
if (profile.username?.trim()) return profile.username.trim();
|
||||
if (profile.email?.trim()) return profile.email.trim();
|
||||
if (profile.phoneNumber?.trim()) return profile.phoneNumber.trim();
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function mapRegisterWithFaydaCitizen(
|
||||
data: RegisterWithFaydaResponse,
|
||||
preferredLanguage = "en",
|
||||
profile?: MeDto | null,
|
||||
): VerifiedCitizen {
|
||||
const fullName =
|
||||
String(
|
||||
data.citizen?.fullName ??
|
||||
data.fullName ??
|
||||
data.user?.fullName ??
|
||||
resolveLocalizedName(data.user?.name, preferredLanguage) ??
|
||||
resolveProfileDisplayName(profile, preferredLanguage) ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const faydaId = String(
|
||||
data.citizen?.faydaId ??
|
||||
data.faydaId ??
|
||||
data.user?.faydaId ??
|
||||
(profile as { faydaId?: string } | undefined)?.faydaId ??
|
||||
profile?.username ??
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.replace(/\s+/g, "");
|
||||
|
||||
return { fullName, faydaId };
|
||||
}
|
||||
|
||||
export async function persistFaydaRegistrationAuth(
|
||||
data: RegisterWithFaydaResponse,
|
||||
): Promise<MeDto | null> {
|
||||
const token = data.token?.trim();
|
||||
const refreshToken = data.refreshToken?.trim();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookieOptions = getAuthCookieOptions(false);
|
||||
setAuthCookies({
|
||||
token,
|
||||
refreshToken: refreshToken ?? "",
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const { data: profile } = await getProfile({
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const userDetails = unwrapApiData(profile as MeDto | { data?: MeDto });
|
||||
|
||||
Cookies.set(
|
||||
"auth-user",
|
||||
JSON.stringify({
|
||||
...userDetails,
|
||||
id: userDetails.id,
|
||||
name: userDetails.name,
|
||||
permissions: userDetails.permissions,
|
||||
roles: userDetails.roles,
|
||||
}),
|
||||
cookieOptions,
|
||||
);
|
||||
|
||||
const firstPositionId =
|
||||
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
|
||||
if (firstPositionId) {
|
||||
Cookies.set("current-position-id", firstPositionId, cookieOptions);
|
||||
}
|
||||
|
||||
return userDetails;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
125
apps/edr-freight-web/backoffice/src/shared/utils/faydaOidc.ts
Normal file
125
apps/edr-freight-web/backoffice/src/shared/utils/faydaOidc.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
export interface FaydaOidcOptions {
|
||||
redirectUri?: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
/** PKCE code_verifier matching DEFAULT_CODE_CHALLENGE (S256). */
|
||||
export const FAYDA_CODE_VERIFIER =
|
||||
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
|
||||
const DEFAULT_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
||||
const DEFAULT_NONCE = "g4DEuje5Fx57Vb64dO4oqLHXGT8L8G7g";
|
||||
const DEFAULT_STATE = "ptOO76SD";
|
||||
|
||||
/** OIDC state value that routes the shared /callback to the complaint flow (legacy sign-in). */
|
||||
export const COMPLAINT_FLOW_STATE = "complaint_flow";
|
||||
|
||||
/** Complaint flow OIDC states — distinguish sign-in vs sign-up endpoints. */
|
||||
export const COMPLAINT_SIGNIN_STATE = "complaint_signin";
|
||||
export const COMPLAINT_SIGNUP_STATE = "complaint_signup";
|
||||
|
||||
export function isComplaintFaydaState(state: string | null): boolean {
|
||||
return (
|
||||
state === COMPLAINT_FLOW_STATE ||
|
||||
state === COMPLAINT_SIGNIN_STATE ||
|
||||
state === COMPLAINT_SIGNUP_STATE
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveComplaintAuthMode(
|
||||
state: string | null,
|
||||
): "signin" | "signup" {
|
||||
if (state === COMPLAINT_SIGNUP_STATE) {
|
||||
return "signup";
|
||||
}
|
||||
return "signin";
|
||||
}
|
||||
|
||||
/** OIDC state for unified external-portal Fayda auth (sign-in or sign-up). */
|
||||
export const EXTERNAL_PORTAL_CONTINUE_STATE = "external_continue";
|
||||
|
||||
/** @deprecated Legacy sign-in state — still accepted on callback. */
|
||||
export const EXTERNAL_PORTAL_SIGNIN_STATE = "external_signin";
|
||||
/** @deprecated Legacy sign-up state — still accepted on callback. */
|
||||
export const EXTERNAL_PORTAL_SIGNUP_STATE = "external_signup";
|
||||
|
||||
export function isExternalPortalFaydaState(state: string | null): boolean {
|
||||
return (
|
||||
state === EXTERNAL_PORTAL_CONTINUE_STATE ||
|
||||
state === EXTERNAL_PORTAL_SIGNIN_STATE ||
|
||||
state === EXTERNAL_PORTAL_SIGNUP_STATE
|
||||
);
|
||||
}
|
||||
|
||||
export function startExternalPortalFaydaAuth(): void {
|
||||
const authUrl = generateFaydaAuthorizationUrl({
|
||||
state: EXTERNAL_PORTAL_CONTINUE_STATE,
|
||||
});
|
||||
window.location.href = authUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the FAYDA/MOSIP OIDC authorization URL.
|
||||
* Shared by external-portal registration and anonymous complaint flows.
|
||||
*/
|
||||
export function generateFaydaAuthorizationUrl(
|
||||
options: FaydaOidcOptions = {},
|
||||
): string {
|
||||
const redirectUri =
|
||||
options.redirectUri ||
|
||||
getDefaultFaydaRedirectUri();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: import.meta.env.VITE_CLIENT_ID || "",
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
acr_values:
|
||||
"mosip:idp:acr:generated-code mosip:idp:acr:linked-wallet mosip:idp:acr:biometrics",
|
||||
claims:
|
||||
'{"userinfo":{"individual_id":{"essential":true},"name":{"essential":true},"phone_number":{"essential":true},"email":{"essential":true},"picture":{"essential":true},"gender":{"essential":true},"birthdate":{"essential":true},"address":{"essential":true}},"id_token":{}}',
|
||||
claims_locales: "en am",
|
||||
code_challenge: DEFAULT_CODE_CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
display: "page",
|
||||
nonce: options.nonce || DEFAULT_NONCE,
|
||||
state: options.state || DEFAULT_STATE,
|
||||
ui_locales: "en am",
|
||||
});
|
||||
|
||||
const endpoint = import.meta.env.VITE_AUTHORIZATION_ENDPOINT || "";
|
||||
return `${endpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default redirect URI registered with FAYDA (external portal flow).
|
||||
*/
|
||||
export function getDefaultFaydaRedirectUri(): string {
|
||||
return (
|
||||
import.meta.env.VITE_REDIRECT_URI ||
|
||||
`${window.location.origin}/callback`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the redirect URI registered with FAYDA for the complaint flow.
|
||||
*
|
||||
* Must exactly match a URI whitelisted in the FAYDA OIDC client — we reuse
|
||||
* the same /callback path as external-portal registration and distinguish
|
||||
* flows via the `state` parameter (see COMPLAINT_FLOW_STATE).
|
||||
*/
|
||||
export function getComplaintFaydaRedirectUri(): string {
|
||||
return (
|
||||
import.meta.env.VITE_COMPLAINT_REDIRECT_URI ||
|
||||
getDefaultFaydaRedirectUri()
|
||||
);
|
||||
}
|
||||
|
||||
export function startComplaintFaydaAuth(): void {
|
||||
const authUrl = generateFaydaAuthorizationUrl({
|
||||
redirectUri: getComplaintFaydaRedirectUri(),
|
||||
state: COMPLAINT_FLOW_STATE,
|
||||
});
|
||||
window.location.href = authUrl;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type FilterValue = string | number | boolean | string[] | undefined | null;
|
||||
export type FilterParams = Record<string, FilterValue>;
|
||||
|
||||
export const cleanFilterParams = <T extends object>(params: T): Partial<T> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => {
|
||||
if (value === null || value === undefined || value === "") return false;
|
||||
if (Array.isArray(value) && value.length === 0) return false;
|
||||
return true;
|
||||
}),
|
||||
) as Partial<T>;
|
||||
@@ -0,0 +1,86 @@
|
||||
// src/shared/utils/get-final-allowed-file-types.ts
|
||||
const extensionToMimeTypes: Record<string, string[]> = {
|
||||
png: ["image/png"],
|
||||
jpg: ["image/jpeg"],
|
||||
jpeg: ["image/jpeg"],
|
||||
gif: ["image/gif"],
|
||||
pdf: ["application/pdf"],
|
||||
dwg: ["application/acad", "application/x-autocad", "application/x-dwg", "image/vnd.dwg"],
|
||||
dxf: ["application/dxf", "application/x-dxf", "image/vnd.dxf"],
|
||||
dwt: ["application/x-autocad-dwt", "application/x-autocad"],
|
||||
bak: ["application/octet-stream"],
|
||||
"sv$": ["application/octet-stream"],
|
||||
dws: ["application/x-autocad-dws", "application/x-autocad"],
|
||||
mxd: ["application/x-esri-map", "application/octet-stream"],
|
||||
aprx: ["application/x-esri-arcgis-pro-project", "application/octet-stream"],
|
||||
rar: ["application/x-rar-compressed", "application/vnd.rar"],
|
||||
zip: ["application/zip"],
|
||||
};
|
||||
|
||||
const mimeToExtensions: Record<string, string[]> = Object.entries(
|
||||
extensionToMimeTypes
|
||||
).reduce((acc, [ext, mimes]) => {
|
||||
for (const mime of mimes) {
|
||||
if (!acc[mime]) acc[mime] = [];
|
||||
acc[mime].push(`.${ext}`);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
const normalizeToken = (token: string) => token.trim().toLowerCase();
|
||||
|
||||
export const getAllowedMimeTypesFromAccept = (
|
||||
accept: string[] | null | undefined
|
||||
): string[] => {
|
||||
if (!accept || accept.length === 0) return [];
|
||||
|
||||
const mimeSet = new Set<string>();
|
||||
|
||||
for (const rawToken of accept) {
|
||||
const token = normalizeToken(rawToken);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.includes("/")) {
|
||||
mimeSet.add(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const extension = token.startsWith(".") ? token.slice(1) : token;
|
||||
(extensionToMimeTypes[extension] || []).forEach((mime) => mimeSet.add(mime));
|
||||
}
|
||||
|
||||
return Array.from(mimeSet);
|
||||
};
|
||||
|
||||
export const getAllowedExtensionsFromAccept = (
|
||||
accept: string[] | null | undefined
|
||||
): string[] => {
|
||||
if (!accept || accept.length === 0) return [];
|
||||
|
||||
const extensionSet = new Set<string>();
|
||||
|
||||
for (const rawToken of accept) {
|
||||
const token = normalizeToken(rawToken);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.includes("/")) {
|
||||
(mimeToExtensions[token] || []).forEach((ext) => extensionSet.add(ext));
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedExtension = token.startsWith(".") ? token : `.${token}`;
|
||||
extensionSet.add(normalizedExtension);
|
||||
}
|
||||
|
||||
return Array.from(extensionSet);
|
||||
};
|
||||
|
||||
export const getFinalAllowedFileTypes = (
|
||||
accept: string[] | null | undefined
|
||||
): string => {
|
||||
if (!accept || accept.length === 0) return "";
|
||||
|
||||
const result = accept.map((token) => normalizeToken(token)).join(",");
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
import { EthDateTime } from "ethiopian-calendar-date-converter";
|
||||
|
||||
export interface HabeshaDate {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
monthName: string;
|
||||
dayName: string;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export class HabeshaCalendarUtils {
|
||||
// Ethiopian month names in Amharic
|
||||
static readonly MONTH_NAMES_AM = [
|
||||
"መስከረም",
|
||||
"ጥቅምት",
|
||||
"ኅዳር",
|
||||
"ታኅሣሥ",
|
||||
"ጥር",
|
||||
"የካቲት",
|
||||
"መጋቢት",
|
||||
"ሚያዝያ",
|
||||
"ግንቦት",
|
||||
"ሰኔ",
|
||||
"ሐምሌ",
|
||||
"ነሃሴ",
|
||||
"ጳጉሜ",
|
||||
];
|
||||
|
||||
// Ethiopian month names in English
|
||||
static readonly MONTH_NAMES_EN = [
|
||||
"Meskerem",
|
||||
"Tikimt",
|
||||
"Hidar",
|
||||
"Tahsas",
|
||||
"Tir",
|
||||
"Yekatit",
|
||||
"Megabit",
|
||||
"Miazia",
|
||||
"Ginbot",
|
||||
"Sene",
|
||||
"Hamle",
|
||||
"Nehase",
|
||||
"Pagume",
|
||||
];
|
||||
|
||||
// Day names in Amharic
|
||||
static readonly DAY_NAMES_AM = [
|
||||
"እሁድ",
|
||||
"ሰኞ",
|
||||
"ማክሰኞ",
|
||||
"ረቡዕ",
|
||||
"ሐሙስ",
|
||||
"ዓርብ",
|
||||
"ቅዳሜ",
|
||||
];
|
||||
|
||||
// Day names in English
|
||||
static readonly DAY_NAMES_EN = [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert Gregorian date to Habesha date
|
||||
*/
|
||||
static gregorianToHabesha(
|
||||
gregorianDate: Date | string | null | undefined
|
||||
): HabeshaDate {
|
||||
// Handle null/undefined cases
|
||||
if (!gregorianDate) {
|
||||
gregorianDate = new Date();
|
||||
}
|
||||
|
||||
// Convert string to Date if needed
|
||||
if (typeof gregorianDate === "string") {
|
||||
gregorianDate = new Date(gregorianDate);
|
||||
}
|
||||
|
||||
// Validate that we have a valid Date object
|
||||
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
|
||||
gregorianDate = new Date();
|
||||
}
|
||||
|
||||
const ethDateTime = EthDateTime.fromEuropeanDate(gregorianDate);
|
||||
const dayOfWeek = gregorianDate.getDay();
|
||||
|
||||
return {
|
||||
year: ethDateTime.year,
|
||||
month: ethDateTime.month,
|
||||
day: ethDateTime.date,
|
||||
monthName: this.MONTH_NAMES_AM[ethDateTime.month - 1],
|
||||
dayName: this.DAY_NAMES_AM[dayOfWeek],
|
||||
formatted: this.formatHabeshaDate(ethDateTime, dayOfWeek),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Habesha date to Gregorian date
|
||||
*/
|
||||
static habeshaToGregorian(year: number, month: number, day: number): Date {
|
||||
const ethDateTime = new EthDateTime(year, month, day);
|
||||
return ethDateTime.toEuropeanDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Habesha date as string
|
||||
*/
|
||||
static formatHabeshaDate(
|
||||
ethDateTime: { year: number; month: number; date: number },
|
||||
dayOfWeek: number,
|
||||
includeDay: boolean = true
|
||||
): string {
|
||||
const dayName = includeDay ? `${this.DAY_NAMES_AM[dayOfWeek]} ` : "";
|
||||
const monthName = this.MONTH_NAMES_AM[ethDateTime.month - 1];
|
||||
|
||||
return `${dayName}${monthName} ${ethDateTime.date}, ${ethDateTime.year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current Habesha date
|
||||
*/
|
||||
static getCurrentHabeshaDate(): HabeshaDate {
|
||||
return this.gregorianToHabesha(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate year options for dropdowns (Habesha years)
|
||||
*/
|
||||
static generateHabeshaYearOptions(
|
||||
yearsBack: number = 5,
|
||||
yearsAhead: number = 5
|
||||
): Array<{ label: string; value: string }> {
|
||||
const currentHabesha = this.getCurrentHabeshaDate();
|
||||
const startYear = currentHabesha.year - yearsBack;
|
||||
const endYear = currentHabesha.year + yearsAhead;
|
||||
|
||||
const years = [];
|
||||
for (let year = startYear; year <= endYear; year++) {
|
||||
years.push({
|
||||
label: year.toString(),
|
||||
value: year.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
return years.reverse(); // Most recent first
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate month options for dropdowns
|
||||
*/
|
||||
static getMonthOptions(
|
||||
language: "am" | "en" = "am"
|
||||
): Array<{ label: string; value: string }> {
|
||||
const monthNames =
|
||||
language === "am" ? this.MONTH_NAMES_AM : this.MONTH_NAMES_EN;
|
||||
|
||||
return monthNames.map((name, index) => ({
|
||||
label: name,
|
||||
value: (index + 1).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days in a Habesha month
|
||||
*/
|
||||
static getDaysInHabeshaMonth(year: number, month: number): number {
|
||||
// Most months have 30 days, Pagume (13th month) has 5 or 6 days
|
||||
if (month === 13) {
|
||||
// Leap year calculation for Ethiopian calendar
|
||||
return this.isHabeshaLeapYear(year) ? 6 : 5;
|
||||
}
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Habesha year is a leap year
|
||||
*/
|
||||
static isHabeshaLeapYear(year: number): boolean {
|
||||
// Ethiopian leap year calculation
|
||||
return year % 4 === 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date string in various formats to Habesha date
|
||||
*/
|
||||
static parseHabeshaDate(dateString: string): HabeshaDate | null {
|
||||
try {
|
||||
// Try to parse as ISO string first, then convert
|
||||
const gregorianDate = new Date(dateString);
|
||||
if (!isNaN(gregorianDate.getTime())) {
|
||||
return this.gregorianToHabesha(gregorianDate);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display in forms
|
||||
*/
|
||||
static formatForDisplay(
|
||||
date: Date | string | null | undefined,
|
||||
language: "am" | "en" = "am"
|
||||
): string {
|
||||
// Handle null/undefined cases
|
||||
if (!date) {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
// Convert string to Date if needed
|
||||
let gregorianDate: Date;
|
||||
if (typeof date === "string") {
|
||||
gregorianDate = new Date(date);
|
||||
} else {
|
||||
gregorianDate = date;
|
||||
}
|
||||
|
||||
// Validate that we have a valid Date object
|
||||
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
|
||||
gregorianDate = new Date();
|
||||
}
|
||||
|
||||
const habeshaDate = this.gregorianToHabesha(gregorianDate);
|
||||
|
||||
if (language === "en") {
|
||||
const monthName = this.MONTH_NAMES_EN[habeshaDate.month - 1];
|
||||
const dayName = this.DAY_NAMES_EN[gregorianDate.getDay()];
|
||||
return `${dayName} ${monthName} ${habeshaDate.day}, ${habeshaDate.year}`;
|
||||
}
|
||||
|
||||
return habeshaDate.formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date range in Habesha calendar
|
||||
*/
|
||||
static getHabeshaDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): {
|
||||
start: HabeshaDate;
|
||||
end: HabeshaDate;
|
||||
formatted: string;
|
||||
} {
|
||||
const start = this.gregorianToHabesha(startDate);
|
||||
const end = this.gregorianToHabesha(endDate);
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
formatted: `${start.formatted} - ${end.formatted}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default HabeshaCalendarUtils;
|
||||
@@ -0,0 +1,4 @@
|
||||
// src/shared/constants/max-file-size.ts
|
||||
|
||||
// Default max file size in MB if not specified elsewhere
|
||||
export const MAX_FILE_SIZE_MB = 500; // 10 MB
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NavOptions } from "../navigation/navigationtabs";
|
||||
|
||||
export const hasAccess = (
|
||||
item: NavOptions,
|
||||
userPermissions: string[],
|
||||
userRoles: string[]
|
||||
): boolean => {
|
||||
const permsCheck =
|
||||
item.perms?.some((perm) => userPermissions.includes(perm)) ?? true;
|
||||
|
||||
const rolesCheck =
|
||||
item.roles?.some((role) => userRoles.includes(role)) ?? true;
|
||||
|
||||
return permsCheck || rolesCheck;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { refreshAuthToken } from "../services/authService";
|
||||
|
||||
export const getRefreshToken = async (refreshToken: string) => {
|
||||
return await refreshAuthToken({
|
||||
refreshToken: refreshToken,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user