wcz-layout
Version:
1 lines • 16.7 kB
Source Map (JSON)
{"version":3,"file":"utils-DjdexspV.mjs","names":[],"sources":["../src/lib/auth/offline.ts","../src/env.ts","../src/lib/auth/session.ts","../src/lib/auth/user.ts","../src/lib/utils.ts"],"sourcesContent":["/**\n * Offline fallback for identity. The refresh token never leaves the sealed\n * httpOnly cookie — what is cached here is only the display snapshot (user\n * claims and avatar) so the UI and offline route permissions can still work\n * while the server is unreachable. Server requests remain independently\n * authorized. At-rest protection is the device's OS login and disk encryption.\n */\n\nexport const OFFLINE_USER_KEY = \"wcz-auth-user\";\nexport const OFFLINE_PHOTO_KEY = \"wcz-auth-photo\";\n\n/** One shift — matches Intune's default offline grace period. */\nconst GRACE_PERIOD = 12 * 60 * 60 * 1000;\n\ntype Cached<T> = { value: T; savedAt: number };\n\nconst recall = <T>(key: string): T | null => {\n if (typeof localStorage === \"undefined\") return null;\n try {\n const raw = localStorage.getItem(key);\n if (!raw) return null;\n const { value, savedAt } = JSON.parse(raw) as Cached<T>;\n if (Date.now() - savedAt > GRACE_PERIOD) {\n localStorage.removeItem(key);\n return null;\n }\n return value;\n } catch {\n localStorage.removeItem(key);\n return null;\n }\n};\n\nconst remember = (key: string, value: unknown): void => {\n if (typeof localStorage === \"undefined\") return;\n try {\n if (value === null || value === undefined) localStorage.removeItem(key);\n else localStorage.setItem(key, JSON.stringify({ value, savedAt: Date.now() }));\n } catch {\n // Storage full or blocked — the offline fallback is best-effort.\n }\n};\n\nconst isOfflineFailure = (error: unknown): boolean =>\n (typeof navigator !== \"undefined\" && !navigator.onLine) || error instanceof TypeError;\n\n/**\n * Answers from the server when reachable, remembering the result; when the call\n * has a transport failure, replays the last result instead, for up to\n * {@link GRACE_PERIOD} since it was last confirmed.\n *\n * Cached values are untrusted client state. Server authorization must never\n * depend on them.\n */\nexport const withOfflineFallback = async <T>(\n key: string,\n fetchFresh: () => Promise<T>,\n): Promise<T | null> => {\n try {\n const fresh = await fetchFresh();\n remember(key, fresh);\n return fresh;\n } catch (error) {\n if (!isOfflineFailure(error)) throw error;\n return recall<T>(key);\n }\n};\n\n/** Drops the cached identity. Call on logout, before leaving the page. */\nexport const clearSessionSnapshot = (): void => {\n if (typeof localStorage === \"undefined\") return;\n localStorage.removeItem(OFFLINE_USER_KEY);\n localStorage.removeItem(OFFLINE_PHOTO_KEY);\n localStorage.setItem(\"wcz-auth-logout\", String(Date.now()));\n};\n\nif (typeof window !== \"undefined\")\n window.addEventListener(\"storage\", (event) => {\n if (event.key === \"wcz-auth-logout\") location.href = \"/auth/logout\";\n });\n","import { createEnv } from \"@t3-oss/env-core\";\nimport { z } from \"zod\";\n\nexport const clientEnv = createEnv({\n clientPrefix: \"VITE_\",\n client: {\n VITE_MUI_LICENSE_KEY: z.string(),\n },\n runtimeEnv: import.meta.env,\n emptyStringAsUndefined: true,\n});\n\nexport const serverEnv = createEnv({\n server: {\n ENTRA_CLIENT_ID: z.string().min(1),\n ENTRA_TENANT_ID: z.string().min(1),\n ENTRA_CLIENT_SECRET: z.string().min(1),\n SESSION_SECRET: z.string().min(32),\n FILE_BASE_URL: z.string().min(1).optional(),\n APPROVAL_BASE_URL: z.string().min(1).optional(),\n PEOPLESOFT_BASE_URL: z.string().min(1).optional(),\n EMAIL_BASE_URL: z.string().min(1).optional(),\n AI_BASE_URL: z.string().min(1).optional(),\n AI_SUBSCRIPTION_KEY: z.string().min(1).optional(),\n },\n runtimeEnv: process.env,\n emptyStringAsUndefined: true,\n});\n","import { serverEnv } from \"~/env\";\n\nexport const getAppSession = async () => {\n // Dynamic import: `@tanstack/react-start/server` is the server entry and drags\n // in the SSR renderer (`react-dom/server`). Keep it out of the client graph —\n // this helper is only ever called server-side, and all callers already await it.\n // Aliased off the `use*` name so it isn't mistaken for a React hook (it's a\n // server session helper, not a hook).\n const { useSession: getSession } = await import(\"@tanstack/react-start/server\");\n\n return getSession({\n name: \"session\",\n password: serverEnv.SESSION_SECRET,\n });\n};\n","import { queryOptions } from \"@tanstack/react-query\";\nimport { createServerFn, createServerOnlyFn } from \"@tanstack/react-start\";\nimport { scopes as definedScopes } from \"virtual:wcz-layout\";\nimport type { QueryClientParam } from \"~/models/QueryClientParam\";\nimport type { User } from \"~/models/User\";\nimport { OFFLINE_USER_KEY, withOfflineFallback } from \"./offline\";\nimport { getAppSession } from \"./session\";\n\n/**\n * Reads the signed-in user from the session cookie, or null. As a server function\n * it runs in-process when called on the server (SSR, middleware) and as an RPC\n * when called from the client — so it doubles as the client `queryFn`.\n */\nexport const getSessionUser = createServerFn({ method: \"GET\" }).handler(\n async (): Promise<User | null> => {\n const session = await getAppSession();\n return session.data.user ?? null;\n },\n);\n\n/**\n * Client-side query for the signed-in user. Shared between `getUser` (route\n * `beforeLoad`) and the always-mounted observer in `LayoutProvider` — the\n * observer is what seeds the offline snapshot on the very first page load\n * (`beforeLoad` does not re-run after SSR hydration) and what makes\n * `refetchOnReconnect` fire once connectivity returns.\n */\nexport const userQueryOptions = queryOptions({\n queryKey: [\"auth\", \"user\"],\n queryFn: () => withOfflineFallback(OFFLINE_USER_KEY, () => getSessionUser()),\n staleTime: Infinity,\n // Default \"online\" pauses the query (never calling queryFn) once the browser\n // reports no connection — which is exactly when the fallback is needed.\n networkMode: \"offlineFirst\",\n // Back online: re-confirm the session and re-stamp the snapshot's grace period.\n refetchOnReconnect: \"always\",\n});\n\nexport const getUser = ({ queryClient }: QueryClientParam): Promise<User | null> => {\n if (import.meta.env.SSR) return getSessionUser();\n\n return queryClient.ensureQueryData(userQueryOptions);\n};\n\n/**\n * Server-only token acquisition: a delegated access token for the given API\n * scope, minted from the user's session refresh token. Entra rotates the refresh\n * token on each use, so the rotated token is persisted back to the session. Use\n * inside server functions and middleware — it is stripped from the client bundle\n * and throws if called there.\n */\nexport const getAccessToken = createServerOnlyFn(\n async (scopeKey: keyof typeof definedScopes): Promise<string> => {\n const session = await getAppSession();\n if (!session.data.refreshToken) throw new Error(\"No active session. User not signed in.\");\n\n // Dynamic import so `entra` (and its `@azure/msal-node` dependency) stays out\n // of the client graph — this module also exports the isomorphic `getUser`.\n const { acquireDelegatedToken, SessionExpiredError } = await import(\"./entra\");\n try {\n const { accessToken, refreshToken } = await acquireDelegatedToken({\n refreshToken: session.data.refreshToken,\n scopes: definedScopes[scopeKey],\n });\n\n if (refreshToken !== session.data.refreshToken) await session.update({ refreshToken });\n return accessToken;\n } catch (error) {\n // Revoked/expired refresh token: clear the dead session and answer 401 so\n // the client can redirect to /auth/login, instead of surfacing a 500.\n if (error instanceof SessionExpiredError) {\n await session.clear();\n throw Response.json(\n { message: \"Unauthorized: Session expired, sign in again\" },\n { status: 401 },\n );\n }\n throw error;\n }\n },\n);\n","import type { AnyFieldApi } from \"@tanstack/react-form\";\nimport { redirect } from \"@tanstack/react-router\";\nimport type { ParsedLocation } from \"@tanstack/react-router\";\nimport { manifest, permissions } from \"virtual:wcz-layout\";\nimport type { QueryClientParam } from \"~/models/QueryClientParam\";\nimport type { TokenPayload } from \"~/models/TokenPayload\";\nimport type { User } from \"~/models/User\";\nimport { getUser } from \"./auth/user\";\n\nexport const WISTRON_PRIMARY_COLOR = \"#00506E\";\nexport const WISTRON_SECONDARY_COLOR = \"#64DC00\";\n\nexport class Platform {\n static get isAndroid() {\n return /android/i.test(this.userAgent);\n }\n static get isIOS() {\n return /iPad|iPhone|iPod/.test(this.userAgent);\n }\n static get isWindows() {\n return /windows/i.test(this.userAgent);\n }\n static get isMacOS() {\n return /Macintosh|MacIntel|MacPPC|Mac68K/.test(this.userAgent);\n }\n\n private static get userAgent() {\n return typeof navigator === \"undefined\" ? \"\" : navigator.userAgent;\n }\n}\n\ninterface RootRouteHeadOptions {\n manifest?: string;\n}\n\nexport const rootRouteHead = (options?: RootRouteHeadOptions) => ({\n meta: [\n { charSet: \"utf-8\" },\n { name: \"viewport\", content: \"width=device-width, initial-scale=1\" },\n { title: manifest.name },\n { name: \"og:type\", content: \"website\" },\n { name: \"og:title\", content: manifest.name },\n { name: \"og:image\", content: \"/favicon-32x32.png\" },\n ],\n links: [\n { rel: \"apple-touch-icon\", sizes: \"180x180\", href: \"/apple-touch-icon.png\" },\n { rel: \"icon\", type: \"image/png\", sizes: \"32x32\", href: \"/favicon-32x32.png\" },\n { rel: \"icon\", type: \"image/png\", sizes: \"16x16\", href: \"/favicon-16x16.png\" },\n { rel: \"manifest\", href: options?.manifest || \"/manifest.json\" },\n { rel: \"icon\", href: \"/favicon.ico\" },\n ],\n});\n\nexport const requirePermission = (permissionKey: keyof typeof permissions) => {\n return async ({ location, context }: { location: ParsedLocation; context: QueryClientParam }) => {\n const user = await getUser({ queryClient: context.queryClient });\n\n if (!user)\n throw redirect({\n href: `/auth/login?returnTo=${encodeURIComponent(location.href)}`,\n reloadDocument: true,\n });\n\n if (!hasPermission(user, permissionKey))\n throw new Error(\"You do not have permission to access this page.\");\n\n return { user };\n };\n};\n\n/* Internal Utils */\nexport type FormOmittedProps =\n | \"name\"\n | \"value\"\n | \"onChange\"\n | \"onBlur\"\n | \"error\"\n | \"helperText\"\n | \"renderInput\"\n | \"type\"\n | \"aria-label\";\n\nexport const getFieldStatus = (field: AnyFieldApi) => {\n const { meta } = field.state;\n\n const isTouched = meta.isTouched;\n const hasError = !!meta.errors.length;\n const helperText = meta.errors[0]?.message;\n\n return { isTouched, hasError, helperText };\n};\n\nexport const toKebabCase = (str: string): string => {\n return str\n .replaceAll(/([a-z])([A-Z])/g, \"$1-$2\")\n .replaceAll(/[\\s_]+/g, \"-\")\n .replaceAll(/[^a-zA-Z0-9-]/g, \"\")\n .toLowerCase()\n .replaceAll(/-+/g, \"-\")\n .replaceAll(/(^-|-$)/g, \"\");\n};\n\nexport const foldForSearch = (value: unknown): string =>\n String(value ?? \"\")\n .toLowerCase()\n .normalize(\"NFD\")\n .replaceAll(/[\\u{300}-\\u{36F}]/gu, \"\")\n .normalize(\"NFC\");\n\nconst permissionGroups = new Set<string>(Object.values(permissions).flat());\n\nexport const buildUser = (payload: TokenPayload): User => ({\n name: payload.name?.split(\"/\")[0],\n email: payload.preferred_username?.toLowerCase(),\n department: payload.department?.toUpperCase() || \"\",\n employeeId: payload.employeeId?.toUpperCase() || \"\",\n companyName: payload.companyName || \"\",\n groups: payload.groups?.filter((group) => permissionGroups.has(group)) ?? [],\n});\n\nexport const hasPermission = (user: User | null, key: keyof typeof permissions): boolean =>\n user ? permissions[key].some((group) => user.groups.includes(group)) : false;\n"],"mappings":"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"}